真人素材库V3
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.private_portrait.asset_service import asset_to_out, list_assets
|
||||
|
||||
|
||||
async def admin_list_assets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
|
||||
return [asset_to_out(asset, project_name=project_name_map.get(asset.project_id), include_user=True) for asset in assets], total
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.private_portrait.project_service import list_projects, project_to_out, refresh_project_counters
|
||||
|
||||
|
||||
async def admin_list_projects(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
):
|
||||
items, total = await list_projects(db, user_id=user_id, page=page, page_size=page_size, keyword=keyword, status=status, library_type=library_type)
|
||||
await refresh_project_counters(db, [item.id for item in items])
|
||||
return [project_to_out(item, include_user=True) for item in items], total
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitLibraryType
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
|
||||
from app.schemas.private_portrait import PrivatePortraitAdminStatsOut
|
||||
|
||||
|
||||
async def admin_get_private_portrait_stats(db: AsyncSession, *, user_id: str | None = None, library_type: str | None = None) -> PrivatePortraitAdminStatsOut:
|
||||
project_filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
asset_filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
project_filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
asset_filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if library_type:
|
||||
project_filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
asset_filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
|
||||
total_projects = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*project_filters))).scalar_one()
|
||||
rows = await db.execute(
|
||||
select(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status, func.count(PrivatePortraitAsset.id))
|
||||
.where(*asset_filters)
|
||||
.group_by(PrivatePortraitAsset.library_type, PrivatePortraitAsset.asset_type, PrivatePortraitAsset.status)
|
||||
)
|
||||
stats = PrivatePortraitAdminStatsOut(total_projects=int(total_projects or 0))
|
||||
for lib, asset_type, status, count in rows.all():
|
||||
n = int(count or 0)
|
||||
stats.total_assets += n
|
||||
if asset_type == PrivatePortraitAssetType.IMAGE.value:
|
||||
stats.image_assets += n
|
||||
elif asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
stats.video_assets += n
|
||||
if status == PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
stats.active_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
stats.processing_assets += n
|
||||
elif status == PrivatePortraitAssetStatus.FAILED.value:
|
||||
stats.failed_assets += n
|
||||
if lib == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
stats.real_person_assets += n
|
||||
elif lib == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
stats.virtual_assets += n
|
||||
return stats
|
||||
@@ -51,7 +51,7 @@ def _remote_error_http_status(code: str) -> int:
|
||||
|
||||
|
||||
class ArkPrivateAssetClient:
|
||||
"""火山 Ark 私域真人人像素材 API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
"""火山 Ark 私域可信素材 Asset API Client。只做 AK/SK 鉴权调用与响应标准化。"""
|
||||
|
||||
def __init__(self, *, ak: str | None = None, sk: str | None = None, for_celery: bool = False):
|
||||
self.ak = ak or settings.VOLC_SMS_ACCESS_KEY_ID
|
||||
@@ -66,6 +66,12 @@ class ArkPrivateAssetClient:
|
||||
async def get_visual_validate_result(self, *, project_name: str, byted_token: str) -> dict[str, Any]:
|
||||
return await self._call(ArkPrivatePortraitAction.GET_VISUAL_VALIDATE_RESULT, {"BytedToken": byted_token, "ProjectName": project_name})
|
||||
|
||||
async def create_asset_group(self, *, project_name: str, name: str, description: str | None = None, group_type: str = "AIGC") -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"Name": name, "GroupType": group_type, "ProjectName": project_name}
|
||||
if description:
|
||||
payload["Description"] = description
|
||||
return await self._call(ArkPrivatePortraitAction.CREATE_ASSET_GROUP, payload)
|
||||
|
||||
async def create_asset(self, *, project_name: str, group_id: str, url: str, asset_type: str, name: str | None = None) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"GroupId": group_id, "URL": url, "AssetType": asset_type, "ProjectName": project_name}
|
||||
if name:
|
||||
|
||||
@@ -6,33 +6,42 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT,
|
||||
PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT,
|
||||
PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE,
|
||||
PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS,
|
||||
PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitConfigOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
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.quota_service import (
|
||||
count_user_counting_assets,
|
||||
ensure_private_portrait_asset_quota_available,
|
||||
get_user_private_portrait_config,
|
||||
set_user_private_portrait_limit,
|
||||
)
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -53,7 +62,6 @@ def _loads(data: str | None) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _exception_message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
@@ -65,7 +73,7 @@ def _exception_message(exc: Exception) -> str:
|
||||
|
||||
|
||||
def _public_url(url: str) -> str:
|
||||
if url.startswith(("http://", "https://")):
|
||||
if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
|
||||
return url
|
||||
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
@@ -83,36 +91,31 @@ def _remote_group_name(user_id: str, project_name: str) -> str:
|
||||
return f"{user_id}-{safe_name}"[:128]
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
|
||||
used = await count_user_counting_image_assets(db, user_id=user_id)
|
||||
return PrivatePortraitConfigOut(enabled=limit > 0, image_limit=limit, used_image_count=used, remaining_image_count=max(0, limit - used) if limit > 0 else 0)
|
||||
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return asset.preview_url or asset.remote_url or asset.source_url or None
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = (await db.execute(select(User).where(User.id == user_id).limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
user.private_portrait_image_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
def _provider_url(asset: PrivatePortraitAsset) -> str | None:
|
||||
return f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}" if asset.remote_asset_id else None
|
||||
|
||||
|
||||
async def count_user_counting_image_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
statuses = [PrivatePortraitAssetStatus.CREATING.value, PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAssetStatus.ACTIVE.value]
|
||||
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status.in_(statuses)))).scalar_one()
|
||||
return int(total or 0)
|
||||
def _poll_interval_seconds(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_INTERVAL_SECONDS
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS
|
||||
|
||||
|
||||
async def _lock_user_for_upload(db: AsyncSession, *, user_id: str) -> User:
|
||||
# 锁 users 行,避免并发绕过用户总量限制。SQLite 会忽略 FOR UPDATE,不影响本地开发。
|
||||
user = (await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
def _poll_max_count(asset_type: str) -> int:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return PRIVATE_PORTRAIT_VIDEO_ASSET_POLL_MAX_COUNT
|
||||
return PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT
|
||||
|
||||
|
||||
def _assert_enabled_asset_type(asset_type: str) -> None:
|
||||
if asset_type not in {item.value for item in PrivatePortraitAssetType}:
|
||||
raise HTTPException(status_code=400, detail="asset_type 仅支持 Image/Video,Audio 暂未开放")
|
||||
if asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video")
|
||||
|
||||
|
||||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||
@@ -137,12 +140,14 @@ def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_
|
||||
|
||||
|
||||
def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None, include_user: bool = False) -> PrivatePortraitAssetOut:
|
||||
display_url = _asset_display_url(asset)
|
||||
return PrivatePortraitAssetOut(
|
||||
id=asset.id,
|
||||
user_id=asset.user_id if include_user else None,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name,
|
||||
group_id=asset.group_id,
|
||||
library_type=asset.library_type,
|
||||
remote_group_id=asset.remote_group_id,
|
||||
remote_asset_id=asset.remote_asset_id,
|
||||
remote_project_name=asset.remote_project_name,
|
||||
@@ -150,8 +155,14 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
name=asset.name,
|
||||
source_url=asset.source_url,
|
||||
preview_url=asset.preview_url,
|
||||
display_url=display_url,
|
||||
provider_url=_provider_url(asset),
|
||||
remote_url=asset.remote_url,
|
||||
remote_url_expired_at=asset.remote_url_expired_at,
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_url,
|
||||
file_size=asset.file_size,
|
||||
mime_type=asset.mime_type,
|
||||
status=asset.status,
|
||||
moderation=_loads(asset.moderation_json),
|
||||
last_poll_at=asset.last_poll_at,
|
||||
@@ -166,15 +177,18 @@ def asset_to_out(asset: PrivatePortraitAsset, *, project_name: str | None = None
|
||||
)
|
||||
|
||||
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> PrivatePortraitAssetGroup | None:
|
||||
async def _get_existing_active_group(db: AsyncSession, *, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup | None:
|
||||
filters = [
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAssetGroup.library_type == library_type)
|
||||
return (
|
||||
await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
)
|
||||
.where(*filters)
|
||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
@@ -182,7 +196,9 @@ async def _get_existing_active_group(db: AsyncSession, *, project_id: str) -> Pr
|
||||
|
||||
|
||||
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id)
|
||||
if project.library_type != PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
raise HTTPException(status_code=400, detail="虚拟人像项目不支持真人认证")
|
||||
active_group = await _get_existing_active_group(db, project_id=project.id, library_type=project.library_type)
|
||||
if active_group or project.status == PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
||||
|
||||
@@ -206,12 +222,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
select(PrivatePortraitValidateSession)
|
||||
.where(
|
||||
PrivatePortraitValidateSession.project_id == project.id,
|
||||
PrivatePortraitValidateSession.status.in_(
|
||||
[
|
||||
PrivatePortraitValidateSessionStatus.CREATED.value,
|
||||
PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value,
|
||||
]
|
||||
),
|
||||
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
|
||||
PrivatePortraitValidateSession.expired_at.is_not(None),
|
||||
PrivatePortraitValidateSession.expired_at > now,
|
||||
)
|
||||
@@ -223,7 +234,7 @@ async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePort
|
||||
|
||||
|
||||
async def create_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None) -> PrivatePortraitValidateSession:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
reusable_session = await _ensure_project_can_validate(db, project=project)
|
||||
if reusable_session:
|
||||
return reusable_session
|
||||
@@ -246,10 +257,8 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||||
session.raw_response_json = _json(resp)
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, detail={"remote_project_name": project.remote_project_name, "library_type": project.library_type})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -259,6 +268,7 @@ async def create_validate_session(db: AsyncSession, *, user_id: str, project_id:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_SESSION_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
async def get_validate_session(db: AsyncSession, *, user_id: str | None, session_id: str) -> PrivatePortraitValidateSession:
|
||||
filters = [PrivatePortraitValidateSession.id == session_id]
|
||||
if user_id is not None:
|
||||
@@ -304,7 +314,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
raise HTTPException(status_code=400, detail=session.error_message)
|
||||
|
||||
try:
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id)
|
||||
existing_group = await _get_existing_active_group(db, project_id=session.project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
if existing_group:
|
||||
session.remote_group_id = existing_group.remote_group_id
|
||||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
||||
@@ -330,10 +340,11 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
id=generate_id(),
|
||||
user_id=session.user_id,
|
||||
project_id=session.project_id,
|
||||
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
remote_group_id=group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=session.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_GROUP_TYPE,
|
||||
group_type=PRIVATE_PORTRAIT_REAL_PERSON_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
@@ -347,7 +358,7 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
await refresh_project_counters(db, [session.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(session)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, detail={"remote_group_id": group_id, "remote_project_name": session.remote_project_name, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value})
|
||||
return session
|
||||
except Exception as exc:
|
||||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||||
@@ -358,51 +369,67 @@ async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_p
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, exc=exc)
|
||||
raise
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitAssetGroup:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
|
||||
async def get_project_active_group(db: AsyncSession, *, user_id: str, project_id: str, library_type: str | None = None) -> PrivatePortraitAssetGroup:
|
||||
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="请先完成真人授权认证,再上传素材")
|
||||
result = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.user_id == user_id, PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value, PrivatePortraitAssetGroup.deleted_at.is_(None)).order_by(PrivatePortraitAssetGroup.created_at.desc()).limit(1))
|
||||
detail = "请先完成真人授权认证,再上传素材" if project.library_type == PrivatePortraitLibraryType.REAL_PERSON.value else "虚拟人像素材组尚未创建成功,不能上传素材"
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(
|
||||
PrivatePortraitAssetGroup.user_id == user_id,
|
||||
PrivatePortraitAssetGroup.project_id == project_id,
|
||||
PrivatePortraitAssetGroup.library_type == project.library_type,
|
||||
PrivatePortraitAssetGroup.status == PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
PrivatePortraitAssetGroup.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=400, detail="请先完成真人授权认证,再上传素材")
|
||||
raise HTTPException(status_code=400, detail="项目没有可用的远程素材组")
|
||||
return group
|
||||
|
||||
|
||||
async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate) -> PrivatePortraitAsset:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def create_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
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="项目正在真人认证或认证未通过,不能上传素材")
|
||||
if payload.asset_type != PrivatePortraitAssetType.IMAGE.value:
|
||||
raise HTTPException(status_code=400, detail="第一版真人素材库仅开放 Image 图片素材")
|
||||
user = await _lock_user_for_upload(db, user_id=user_id)
|
||||
limit = int(getattr(user, "private_portrait_image_limit", PRIVATE_PORTRAIT_DEFAULT_IMAGE_LIMIT) or 0)
|
||||
if limit <= 0:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_DISABLED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, message="用户真人素材模块未启用")
|
||||
raise HTTPException(status_code=403, detail="真人素材库未启用")
|
||||
current_count = await count_user_counting_image_assets(db, user_id=user_id)
|
||||
if current_count >= limit:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_REJECT_MAX_LIMIT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, detail={"current_count": current_count, "limit": limit})
|
||||
raise HTTPException(status_code=400, detail=f"你的真人素材库最多可上传 {limit} 张图片,请删除已有素材后再上传")
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id)
|
||||
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)
|
||||
asset = PrivatePortraitAsset(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
library_type=project.library_type,
|
||||
remote_group_id=group.remote_group_id,
|
||||
remote_project_name=project.remote_project_name,
|
||||
asset_type=payload.asset_type,
|
||||
name=payload.name,
|
||||
source_url=public_url,
|
||||
preview_url=payload.url,
|
||||
video_duration=payload.video_duration,
|
||||
video_cover_url=payload.video_cover_url,
|
||||
file_size=payload.file_size,
|
||||
mime_type=payload.mime_type,
|
||||
status=PrivatePortraitAssetStatus.CREATING.value,
|
||||
)
|
||||
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={"limit": limit, "current_count": current_count, "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})
|
||||
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")
|
||||
@@ -411,14 +438,12 @@ async def create_asset(db: AsyncSession, *, user_id: str, project_id: str, paylo
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.remote_asset_id = remote_asset_id
|
||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||||
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
# created_at / updated_at 来自数据库默认值或 onupdate,flush 后可能处于 expired 状态。
|
||||
# 在 async SQLAlchemy 下,响应转换时同步读取 expired 字段会触发 MissingGreenlet。
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_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={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_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={"remote_asset_id": remote_asset_id, "remote_project_name": project.remote_project_name, "library_type": project.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
@@ -434,11 +459,11 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材尚未创建远程 Asset")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
|
||||
|
||||
source = PrivatePortraitEventSource.CELERY.value if user_id is None else PrivatePortraitEventSource.API.value
|
||||
log_operation_event(
|
||||
@@ -449,12 +474,7 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={
|
||||
"status": asset.status,
|
||||
"poll_count": int(asset.poll_count or 0),
|
||||
"remote_asset_id": asset.remote_asset_id,
|
||||
"remote_project_name": asset.remote_project_name,
|
||||
},
|
||||
detail={"status": asset.status, "poll_count": int(asset.poll_count or 0), "remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type},
|
||||
)
|
||||
try:
|
||||
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||
@@ -467,24 +487,15 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
asset.status = status
|
||||
asset.remote_url = remote_resp.get("URL") or remote_resp.get("url") or asset.remote_url
|
||||
asset.moderation_json = _json(remote_resp.get("Moderation") or remote_resp.get("moderation"))
|
||||
max_count = _poll_max_count(asset.asset_type)
|
||||
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT:
|
||||
if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= max_count:
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
asset.error_message = "素材入库轮询超时"
|
||||
asset.next_poll_at = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=source,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"poll_count": asset.poll_count, "max_count": PRIVATE_PORTRAIT_ASSET_POLL_MAX_COUNT, "remote_asset_id": asset.remote_asset_id},
|
||||
error=asset.error_message,
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_TIMEOUT.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"poll_count": asset.poll_count, "max_count": max_count, "remote_asset_id": asset.remote_asset_id, "library_type": asset.library_type, "asset_type": asset.asset_type}, error=asset.error_message)
|
||||
elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value:
|
||||
asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS)
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
else:
|
||||
asset.next_poll_at = None
|
||||
|
||||
@@ -493,23 +504,25 @@ async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id:
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=source,
|
||||
user_id=asset.user_id,
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"status": asset.status, "remote_asset_id": asset.remote_asset_id, "next_poll_at": asset.next_poll_at, "poll_count": asset.poll_count, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
return asset
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value, source=source, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc)
|
||||
raise
|
||||
|
||||
|
||||
async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str | None = None, status: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
|
||||
async def list_assets(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
keyword: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> tuple[list[PrivatePortraitAsset], int, dict[str, str]]:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
filters = [PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
@@ -517,6 +530,10 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
filters.append(PrivatePortraitAsset.user_id == user_id)
|
||||
if project_id:
|
||||
filters.append(PrivatePortraitAsset.project_id == project_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
if asset_type:
|
||||
filters.append(PrivatePortraitAsset.asset_type == asset_type)
|
||||
if status:
|
||||
filters.append(PrivatePortraitAsset.status == status)
|
||||
if keyword:
|
||||
@@ -532,15 +549,35 @@ async def list_assets(db: AsyncSession, *, user_id: str | None, project_id: str
|
||||
return assets, int(total or 0), project_name_map
|
||||
|
||||
|
||||
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size)
|
||||
return [PrivatePortraitSelectableAssetOut(id=asset.id, project_id=asset.project_id, project_name=project_name_map.get(asset.project_id, ""), name=asset.name, asset_type=asset.asset_type, preview_url=asset.preview_url or asset.remote_url, status=asset.status, created_at=asset.created_at) for asset in assets], total
|
||||
async def list_selectable_assets(db: AsyncSession, *, user_id: str, project_id: str | None = None, keyword: str | None = None, page: int = 1, page_size: int = 20, library_type: str | None = None, asset_type: str | None = None) -> tuple[list[PrivatePortraitSelectableAssetOut], int]:
|
||||
assets, total, project_name_map = await list_assets(db, user_id=user_id, project_id=project_id, status=PrivatePortraitAssetStatus.ACTIVE.value, keyword=keyword, page=page, page_size=page_size, library_type=library_type, asset_type=asset_type)
|
||||
return [
|
||||
PrivatePortraitSelectableAssetOut(
|
||||
id=asset.id,
|
||||
project_id=asset.project_id,
|
||||
project_name=project_name_map.get(asset.project_id, ""),
|
||||
library_type=asset.library_type,
|
||||
name=asset.name,
|
||||
asset_type=asset.asset_type,
|
||||
preview_url=asset.preview_url or asset.remote_url,
|
||||
display_url=_asset_display_url(asset),
|
||||
provider_url=_provider_url(asset),
|
||||
video_duration=asset.video_duration,
|
||||
video_cover_url=asset.video_cover_url,
|
||||
status=asset.status,
|
||||
created_at=asset.created_at,
|
||||
)
|
||||
for asset in assets
|
||||
], total
|
||||
|
||||
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) -> PrivatePortraitAsset:
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)).limit(1))).scalar_one_or_none()
|
||||
async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, library_type: str | None = None) -> PrivatePortraitAsset:
|
||||
filters = [PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == user_id, PrivatePortraitAsset.deleted_at.is_(None)]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitAsset.library_type == library_type)
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(*filters).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||||
now = datetime.now(timezone.utc)
|
||||
asset.deleted_at = now
|
||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||
@@ -548,64 +585,30 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str) ->
|
||||
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})
|
||||
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})
|
||||
return asset
|
||||
|
||||
|
||||
async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
|
||||
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1))).scalar_one_or_none()
|
||||
if not asset:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
asset_id=asset_id,
|
||||
message="远程删除跳过:本地素材不存在",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, message="远程删除跳过:本地素材不存在")
|
||||
return
|
||||
if not asset.remote_asset_id:
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||
asset.remote_delete_error = None
|
||||
await db.flush()
|
||||
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",
|
||||
)
|
||||
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},
|
||||
)
|
||||
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})
|
||||
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
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
asset.remote_deleted_at = now
|
||||
asset.remote_delete_error = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.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},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.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})
|
||||
except Exception as exc:
|
||||
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -619,45 +622,18 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||||
group.remote_delete_error = None
|
||||
await db.flush()
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
message="远程删除跳过:素材组没有 remote_group_id",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, message="远程删除跳过:素材组没有 remote_group_id")
|
||||
return
|
||||
client = client or ArkPrivateAssetClient(for_celery=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
|
||||
try:
|
||||
await client.delete_asset_group(project_name=group.remote_project_name, group_id=group.remote_group_id)
|
||||
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||||
group.remote_deleted_at = now
|
||||
group.remote_delete_error = None
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
user_id=group.user_id,
|
||||
project_id=group.project_id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, detail={"remote_group_id": group.remote_group_id, "remote_project_name": group.remote_project_name, "library_type": group.library_type})
|
||||
except Exception as exc:
|
||||
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
||||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||||
@@ -667,14 +643,7 @@ async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortrait
|
||||
|
||||
|
||||
async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
project_id=project_id,
|
||||
message="开始远程删除真人素材项目资源",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="开始远程删除私域人像素材项目资源")
|
||||
rows = await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id))
|
||||
for asset in rows.scalars().all():
|
||||
await delete_asset_remote(db, asset_id=asset.id)
|
||||
@@ -682,14 +651,7 @@ async def delete_project_remote(db: AsyncSession, *, project_id: str) -> None:
|
||||
client = ArkPrivateAssetClient(for_celery=True)
|
||||
for group in groups.scalars().all():
|
||||
await _delete_asset_group_remote(db, group=group, client=client)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
project_id=project_id,
|
||||
message="远程删除真人素材项目资源完成",
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, project_id=project_id, message="远程删除私域人像素材项目资源完成")
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -707,13 +669,7 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
.limit(limit)
|
||||
)
|
||||
ids = [row[0] for row in rows.all()]
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"limit": limit, "matched_count": len(ids)},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit, "matched_count": len(ids)})
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
for asset_id in ids:
|
||||
@@ -722,38 +678,15 @@ async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int:
|
||||
success_count += 1
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
asset_id=asset_id,
|
||||
exc=exc,
|
||||
)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count},
|
||||
)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_POLL_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, asset_id=asset_id, exc=exc)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.SYNC_DUE_ASSETS_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value if failed_count == 0 else PrivatePortraitEventStatus.WARNING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"matched_count": len(ids), "success_count": success_count, "failed_count": failed_count})
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[str, int]:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail={"limit": limit},
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, detail={"limit": limit})
|
||||
statuses = [PrivatePortraitRemoteDeleteStatus.PENDING.value, PrivatePortraitRemoteDeleteStatus.FAILED.value]
|
||||
asset_rows = await db.execute(
|
||||
select(PrivatePortraitAsset.id)
|
||||
.where(PrivatePortraitAsset.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAsset.updated_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
asset_rows = await db.execute(select(PrivatePortraitAsset.id).where(PrivatePortraitAsset.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAsset.updated_at.asc()).limit(limit))
|
||||
asset_ids = [row[0] for row in asset_rows.all()]
|
||||
for asset_id in asset_ids:
|
||||
await delete_asset_remote(db, asset_id=asset_id)
|
||||
@@ -761,12 +694,7 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
remaining = max(0, limit - len(asset_ids))
|
||||
group_count = 0
|
||||
if remaining > 0:
|
||||
group_rows = await db.execute(
|
||||
select(PrivatePortraitAssetGroup)
|
||||
.where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses))
|
||||
.order_by(PrivatePortraitAssetGroup.updated_at.asc())
|
||||
.limit(remaining)
|
||||
)
|
||||
group_rows = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.remote_delete_status.in_(statuses)).order_by(PrivatePortraitAssetGroup.updated_at.asc()).limit(remaining))
|
||||
client = ArkPrivateAssetClient(for_celery=True)
|
||||
groups = list(group_rows.scalars().all())
|
||||
group_count = len(groups)
|
||||
@@ -774,12 +702,5 @@ async def recover_remote_deletes_once(db: AsyncSession, *, limit: int) -> dict[s
|
||||
await _delete_asset_group_remote(db, group=group, client=client)
|
||||
|
||||
result = {"asset_count": len(asset_ids), "group_count": group_count, "total_count": len(asset_ids) + group_count}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.CELERY.value,
|
||||
detail=result,
|
||||
)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.REMOTE_DELETE_RECOVERY_DONE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.CELERY.value, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
@@ -27,16 +29,24 @@ DOMAIN = "private_portrait"
|
||||
|
||||
def _safe_slug(value: str, *, max_length: int = 80) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
# 先保留常见英文数字连字符;中文等字符统一转 _,仅用于本地项目 slug。
|
||||
value = re.sub(r"[^a-z0-9_-]+", "_", value)
|
||||
value = re.sub(r"_+", "_", value).strip("_-")
|
||||
return (value[:max_length] or "project")
|
||||
|
||||
|
||||
def _status_for_created_project(library_type: str) -> str:
|
||||
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
return PrivatePortraitProjectStatus.VALIDATING.value
|
||||
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
return PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value
|
||||
raise HTTPException(status_code=400, detail="library_type 不支持")
|
||||
|
||||
|
||||
def project_to_out(project: PrivatePortraitProject, *, include_user: bool = False) -> PrivatePortraitProjectOut:
|
||||
return PrivatePortraitProjectOut(
|
||||
id=project.id,
|
||||
user_id=project.user_id if include_user else None,
|
||||
library_type=project.library_type,
|
||||
name=project.name,
|
||||
name_slug=project.name_slug,
|
||||
remote_project_name=project.remote_project_name,
|
||||
@@ -44,37 +54,57 @@ def project_to_out(project: PrivatePortraitProject, *, include_user: bool = Fals
|
||||
status=project.status,
|
||||
asset_group_count=project.asset_group_count or 0,
|
||||
asset_count=project.asset_count or 0,
|
||||
image_asset_count=getattr(project, "image_asset_count", 0) or 0,
|
||||
video_asset_count=getattr(project, "video_asset_count", 0) or 0,
|
||||
active_asset_count=project.active_asset_count or 0,
|
||||
active_image_asset_count=getattr(project, "active_image_asset_count", 0) or 0,
|
||||
active_video_asset_count=getattr(project, "active_video_asset_count", 0) or 0,
|
||||
last_used_at=project.last_used_at,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject).where(
|
||||
PrivatePortraitProject.id == project_id,
|
||||
PrivatePortraitProject.user_id == user_id,
|
||||
PrivatePortraitProject.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
async def get_user_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
filters = [
|
||||
PrivatePortraitProject.id == project_id,
|
||||
PrivatePortraitProject.user_id == user_id,
|
||||
PrivatePortraitProject.deleted_at.is_(None),
|
||||
]
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
result = await db.execute(select(PrivatePortraitProject).where(*filters).limit(1))
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="真人素材项目不存在")
|
||||
raise HTTPException(status_code=404, detail="私域人像素材项目不存在")
|
||||
return project
|
||||
|
||||
|
||||
async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate) -> PrivatePortraitProject:
|
||||
async def create_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
payload: PrivatePortraitProjectCreate,
|
||||
library_type: str = PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
status: str | None = None,
|
||||
remote_project_name: str = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
) -> PrivatePortraitProject:
|
||||
slug = _safe_slug(payload.name)
|
||||
project = PrivatePortraitProject(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
library_type=library_type,
|
||||
name=payload.name.strip(),
|
||||
name_slug=slug,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
remote_project_name=remote_project_name,
|
||||
description=payload.description,
|
||||
status=PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
status=status or _status_for_created_project(library_type),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -85,35 +115,38 @@ async def create_project(db: AsyncSession, *, user_id: str, payload: PrivatePort
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="创建待认证真人素材项目",
|
||||
detail={"name": project.name, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
message="创建私域人像素材项目",
|
||||
detail={"name": project.name, "library_type": library_type, "remote_project_name": project.remote_project_name, "status": project.status},
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
async def update_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def update_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
payload: PrivatePortraitProjectUpdate,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
before = {
|
||||
"name": project.name,
|
||||
"name_slug": project.name_slug,
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
if payload.name is not None:
|
||||
new_name = payload.name.strip()
|
||||
if new_name and new_name != project.name:
|
||||
project.name = new_name
|
||||
project.name_slug = _safe_slug(new_name)
|
||||
project.remote_project_name = PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
if payload.status is not None:
|
||||
allowed_statuses = {
|
||||
PrivatePortraitProjectStatus.VALIDATING.value,
|
||||
PrivatePortraitProjectStatus.ACTIVE.value,
|
||||
PrivatePortraitProjectStatus.VALIDATE_FAILED.value,
|
||||
}
|
||||
allowed_statuses = {item.value for item in PrivatePortraitProjectStatus if item != PrivatePortraitProjectStatus.DELETED}
|
||||
if payload.status not in allowed_statuses:
|
||||
raise HTTPException(status_code=400, detail="项目状态不支持")
|
||||
project.status = payload.status
|
||||
@@ -124,6 +157,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
"remote_project_name": project.remote_project_name,
|
||||
"description": project.description,
|
||||
"status": project.status,
|
||||
"library_type": project.library_type,
|
||||
}
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
@@ -132,7 +166,7 @@ async def update_project(db: AsyncSession, *, user_id: str, project_id: str, pay
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="更新真人素材项目",
|
||||
message="更新私域人像素材项目",
|
||||
detail={"before": before, "after": after},
|
||||
)
|
||||
return project
|
||||
@@ -146,18 +180,27 @@ async def list_projects(
|
||||
page_size: int = 20,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
library_type: str | None = None,
|
||||
) -> tuple[list[PrivatePortraitProject], int]:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
filters = [PrivatePortraitProject.deleted_at.is_(None)]
|
||||
if user_id:
|
||||
filters.append(PrivatePortraitProject.user_id == user_id)
|
||||
if library_type:
|
||||
filters.append(PrivatePortraitProject.library_type == library_type)
|
||||
if keyword:
|
||||
filters.append(PrivatePortraitProject.name.ilike(f"%{keyword.strip()}%"))
|
||||
if status:
|
||||
filters.append(PrivatePortraitProject.status == status)
|
||||
total = (await db.execute(select(func.count(PrivatePortraitProject.id)).where(*filters))).scalar_one()
|
||||
result = await db.execute(select(PrivatePortraitProject).where(*filters).order_by(PrivatePortraitProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
|
||||
result = await db.execute(
|
||||
select(PrivatePortraitProject)
|
||||
.where(*filters)
|
||||
.order_by(PrivatePortraitProject.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), int(total or 0)
|
||||
|
||||
|
||||
@@ -174,27 +217,73 @@ async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) ->
|
||||
select(
|
||||
PrivatePortraitAsset.project_id,
|
||||
func.count(PrivatePortraitAsset.id),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
|
||||
func.sum(case((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.IMAGE.value), 1), else_=0)),
|
||||
func.sum(case(((PrivatePortraitAsset.status == PrivatePortraitAssetStatus.ACTIVE.value) & (PrivatePortraitAsset.asset_type == PrivatePortraitAssetType.VIDEO.value), 1), else_=0)),
|
||||
)
|
||||
.where(PrivatePortraitAsset.project_id.in_(project_ids), PrivatePortraitAsset.deleted_at.is_(None))
|
||||
.group_by(PrivatePortraitAsset.project_id)
|
||||
)
|
||||
group_count_map = {pid: int(count or 0) for pid, count in group_rows.all()}
|
||||
asset_count_map: dict[str, tuple[int, int]] = {}
|
||||
for pid, total, active_total in asset_rows.all():
|
||||
asset_count_map[pid] = (int(total or 0), int(active_total or 0))
|
||||
asset_count_map: dict[str, tuple[int, int, int, int, int, int]] = {}
|
||||
for pid, total, image_total, video_total, active_total, active_image_total, active_video_total in asset_rows.all():
|
||||
asset_count_map[pid] = (
|
||||
int(total or 0),
|
||||
int(image_total or 0),
|
||||
int(video_total or 0),
|
||||
int(active_total or 0),
|
||||
int(active_image_total or 0),
|
||||
int(active_video_total or 0),
|
||||
)
|
||||
for pid in project_ids:
|
||||
total, active_total = asset_count_map.get(pid, (0, 0))
|
||||
await db.execute(update(PrivatePortraitProject).where(PrivatePortraitProject.id == pid).values(asset_group_count=group_count_map.get(pid, 0), asset_count=total, active_asset_count=active_total))
|
||||
total, image_total, video_total, active_total, active_image_total, active_video_total = asset_count_map.get(pid, (0, 0, 0, 0, 0, 0))
|
||||
await db.execute(
|
||||
update(PrivatePortraitProject)
|
||||
.where(PrivatePortraitProject.id == pid)
|
||||
.values(
|
||||
asset_group_count=group_count_map.get(pid, 0),
|
||||
asset_count=total,
|
||||
image_asset_count=image_total,
|
||||
video_asset_count=video_total,
|
||||
active_asset_count=active_total,
|
||||
active_image_asset_count=active_image_total,
|
||||
active_video_asset_count=active_video_total,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def soft_delete_project(db: AsyncSession, *, user_id: str, project_id: str) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id)
|
||||
async def soft_delete_project(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
now = datetime.now(timezone.utc)
|
||||
project.deleted_at = now
|
||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
||||
await db.execute(update(PrivatePortraitAsset).where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
|
||||
await db.execute(update(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None)).values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value))
|
||||
await db.execute(
|
||||
update(PrivatePortraitAsset)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
.values(deleted_at=now, status=PrivatePortraitAssetStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
)
|
||||
await db.execute(
|
||||
update(PrivatePortraitAssetGroup)
|
||||
.where(PrivatePortraitAssetGroup.project_id == project_id, PrivatePortraitAssetGroup.deleted_at.is_(None))
|
||||
.values(deleted_at=now, status=PrivatePortraitAssetGroupStatus.LOCAL_DELETED.value, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
|
||||
)
|
||||
await db.flush()
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, message="本地软删真人素材项目", detail={"remote_project_name": project.remote_project_name})
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.PROJECT_DELETE.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="本地软删私域人像素材项目",
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitConfigOut
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
_COUNTING_STATUSES = {
|
||||
PrivatePortraitAssetStatus.CREATING.value,
|
||||
PrivatePortraitAssetStatus.PROCESSING.value,
|
||||
PrivatePortraitAssetStatus.ACTIVE.value,
|
||||
}
|
||||
_COUNTING_LIBRARY_TYPES = {
|
||||
PrivatePortraitLibraryType.REAL_PERSON.value,
|
||||
PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
}
|
||||
|
||||
|
||||
async def get_user_or_404(db: AsyncSession, *, user_id: str, for_update: bool = False) -> User:
|
||||
stmt = select(User).where(User.id == user_id).limit(1)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
user = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_user_asset_limit_value(user: User) -> int:
|
||||
return int(getattr(user, "private_portrait_asset_limit", PRIVATE_PORTRAIT_DEFAULT_ASSET_LIMIT) or 0)
|
||||
|
||||
|
||||
async def count_user_counting_assets(db: AsyncSession, *, user_id: str) -> int:
|
||||
total = (
|
||||
await db.execute(
|
||||
select(func.count(PrivatePortraitAsset.id)).where(
|
||||
PrivatePortraitAsset.user_id == user_id,
|
||||
PrivatePortraitAsset.library_type.in_(_COUNTING_LIBRARY_TYPES),
|
||||
PrivatePortraitAsset.asset_type.in_(PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES),
|
||||
PrivatePortraitAsset.deleted_at.is_(None),
|
||||
PrivatePortraitAsset.status.in_(_COUNTING_STATUSES),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
async def get_user_private_portrait_config(db: AsyncSession, *, user_id: str) -> PrivatePortraitConfigOut:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
remaining = max(0, limit - used) if limit > 0 else 0
|
||||
return PrivatePortraitConfigOut(
|
||||
enabled=limit > 0,
|
||||
asset_limit=limit,
|
||||
used_asset_count=used,
|
||||
remaining_asset_count=remaining,
|
||||
image_limit=limit,
|
||||
used_image_count=used,
|
||||
remaining_image_count=remaining,
|
||||
)
|
||||
|
||||
|
||||
async def set_user_private_portrait_limit(db: AsyncSession, *, user_id: str, limit: int) -> User:
|
||||
user = await get_user_or_404(db, user_id=user_id)
|
||||
user.private_portrait_asset_limit = max(0, int(limit))
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def ensure_private_portrait_asset_quota_available(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str | None = None,
|
||||
library_type: str | None = None,
|
||||
asset_type: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
user = await get_user_or_404(db, user_id=user_id, for_update=True)
|
||||
limit = get_user_asset_limit_value(user)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
if limit <= 0:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "library_type": library_type, "asset_type": asset_type, "reason": "disabled"},
|
||||
message="用户私域人像素材库未启用",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="私域人像素材库未启用")
|
||||
|
||||
used = await count_user_counting_assets(db, user_id=user_id)
|
||||
if used >= limit:
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_DENY.value,
|
||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "library_type": library_type, "asset_type": asset_type, "reason": "max_limit"},
|
||||
message="用户私域人像素材总量已达上限",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"你的私域人像素材库最多可上传 {limit} 个素材,请删除已有素材后再上传")
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.QUOTA_CHECK_PASS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.SERVICE.value,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
detail={"asset_limit": limit, "used_asset_count": used, "remaining_asset_count": max(0, limit - used), "library_type": library_type, "asset_type": asset_type},
|
||||
)
|
||||
return limit, used
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.real_person.service import *
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import PrivatePortraitLibraryType
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectCreate, PrivatePortraitProjectUpdate
|
||||
from app.services.private_portrait.asset_service import create_asset, create_validate_session
|
||||
from app.services.private_portrait.project_service import create_project, update_project
|
||||
|
||||
|
||||
async def create_real_person_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitProjectCreate):
|
||||
return await create_project(db, user_id=user_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def update_real_person_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
return await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
|
||||
|
||||
async def create_real_person_validate_session(db: AsyncSession, *, user_id: str, project_id: str, callback_redirect_url: str | None = None):
|
||||
return await create_validate_session(db, user_id=user_id, project_id=project_id, callback_redirect_url=callback_redirect_url)
|
||||
|
||||
|
||||
async def create_real_person_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_ASSET_URI_PREFIX,
|
||||
PrivatePortraitAssetStatus,
|
||||
PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES,
|
||||
PrivatePortraitAssetType,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
@@ -25,7 +26,6 @@ DOMAIN = "private_portrait"
|
||||
_ASSET_TYPE_TO_REFERENCE_TYPE = {
|
||||
PrivatePortraitAssetType.IMAGE.value: "image",
|
||||
PrivatePortraitAssetType.VIDEO.value: "video",
|
||||
PrivatePortraitAssetType.AUDIO.value: "audio",
|
||||
}
|
||||
|
||||
|
||||
@@ -260,24 +260,26 @@ async def resolve_private_portrait_references(
|
||||
|
||||
asset_id = str(_ref_get(ref, "private_asset_id") or "")
|
||||
if not asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材引用缺少 private_asset_id")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材引用缺少 private_asset_id")
|
||||
|
||||
asset = asset_map.get(asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=400, detail="真人素材不存在")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材不存在")
|
||||
if asset.user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail="真人素材不属于当前用户")
|
||||
raise HTTPException(status_code=403, detail="私域人像素材不属于当前用户")
|
||||
if asset.deleted_at is not None:
|
||||
raise HTTPException(status_code=400, detail="真人素材已删除")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||||
if asset.asset_type not in PRIVATE_PORTRAIT_ENABLED_ASSET_TYPES:
|
||||
raise HTTPException(status_code=400, detail="Audio 暂未开放,当前仅支持 Image/Video 私域素材")
|
||||
if asset.status != PrivatePortraitAssetStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材状态为 {asset.status},Active 后才可用于生成")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材状态为 {asset.status},Active 后才可用于生成")
|
||||
if not asset.remote_asset_id:
|
||||
raise HTTPException(status_code=400, detail="真人素材缺少远程 AssetId")
|
||||
raise HTTPException(status_code=400, detail="私域人像素材缺少远程 AssetId")
|
||||
|
||||
expected_ref_type = _ASSET_TYPE_TO_REFERENCE_TYPE.get(asset.asset_type)
|
||||
ref_type = _normalize_ref_type(_ref_get(ref, "type"))
|
||||
if expected_ref_type and ref_type and ref_type != expected_ref_type:
|
||||
raise HTTPException(status_code=400, detail=f"真人素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
raise HTTPException(status_code=400, detail=f"私域人像素材类型不匹配:引用为 {ref_type},素材为 {expected_ref_type}")
|
||||
|
||||
provider_url = f"{PRIVATE_PORTRAIT_ASSET_URI_PREFIX}{asset.remote_asset_id}"
|
||||
_ref_set(ref, "source", PrivatePortraitReferenceSource.PRIVATE_PORTRAIT_ASSET.value)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.private_portrait.virtual.service import *
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
PrivatePortraitAssetGroupStatus,
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
PrivatePortraitProjectStatus,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAssetGroup
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitProjectUpdate, PrivatePortraitVirtualProjectCreate
|
||||
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.asset_service import create_asset
|
||||
from app.services.private_portrait.project_service import create_project, refresh_project_counters, update_project
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
|
||||
|
||||
def _json(data) -> str | None:
|
||||
if data is None:
|
||||
return None
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _remote_group_name(user_id: str, project_name: str) -> str:
|
||||
safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in project_name.strip())[:80]
|
||||
return f"virtual-{user_id}-{safe_name}"[:128]
|
||||
|
||||
|
||||
async def create_virtual_project(db: AsyncSession, *, user_id: str, payload: PrivatePortraitVirtualProjectCreate):
|
||||
project = await create_project(
|
||||
db,
|
||||
user_id=user_id,
|
||||
payload=payload, # type: ignore[arg-type]
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
|
||||
remote_project_name=PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
|
||||
)
|
||||
remote_group_name = _remote_group_name(user_id, project.name)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
detail={"remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name, "group_type": PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE},
|
||||
)
|
||||
try:
|
||||
resp = await ArkPrivateAssetClient().create_asset_group(
|
||||
project_name=project.remote_project_name,
|
||||
name=remote_group_name,
|
||||
description=project.description,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
)
|
||||
remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId")
|
||||
if not remote_group_id:
|
||||
raise RuntimeError("CreateAssetGroup 未返回素材组 ID")
|
||||
group = PrivatePortraitAssetGroup(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
|
||||
remote_group_id=remote_group_id,
|
||||
remote_group_name=remote_group_name,
|
||||
remote_project_name=project.remote_project_name,
|
||||
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
|
||||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||||
raw_response_json=_json(resp),
|
||||
)
|
||||
db.add(group)
|
||||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
await db.refresh(project)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
detail={"remote_group_id": remote_group_id, "remote_group_name": remote_group_name, "remote_project_name": project.remote_project_name},
|
||||
)
|
||||
return project
|
||||
except Exception as exc:
|
||||
project.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
|
||||
await db.flush()
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
exc=exc,
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"创建火山虚拟人像素材组失败:{exc}") from exc
|
||||
|
||||
|
||||
async def update_virtual_project(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitProjectUpdate):
|
||||
project = await update_project(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
# 远程同步失败不影响本地更新,记录日志便于排查。
|
||||
try:
|
||||
# 只同步当前激活组。
|
||||
from app.services.private_portrait.asset_service import get_project_active_group
|
||||
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
await ArkPrivateAssetClient().update_asset_group(project_name=project.remote_project_name, group_id=group.remote_group_id, name=group.remote_group_name, title=project.name, description=project.description)
|
||||
except Exception as exc:
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project_id, exc=exc)
|
||||
return project
|
||||
|
||||
|
||||
async def create_virtual_asset(db: AsyncSession, *, user_id: str, project_id: str, payload: PrivatePortraitAssetCreate):
|
||||
return await create_asset(db, user_id=user_id, project_id=project_id, payload=payload, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
|
||||
Reference in New Issue
Block a user