1247 lines
55 KiB
Python
1247 lines
55 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Awaitable, Callable
|
||
from urllib.parse import urlencode
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import func, or_, 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_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.enums.upload_resource import (
|
||
UploadResourceBindStatusEnum,
|
||
UploadResourceDeletePolicyEnum,
|
||
UploadResourceModuleEnum,
|
||
UploadResourceSourceModelEnum,
|
||
UploadResourceTypeEnum,
|
||
)
|
||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||
from app.models.upload_resource import UploadResource
|
||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient, ArkPrivateAssetRemoteError
|
||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
||
from app.services.private_portrait.upload_service import private_portrait_upload_module
|
||
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
|
||
from app.services.upload_resource.path_resolver import upload_url_to_storage_path
|
||
from app.services.private_portrait.quota_service import (
|
||
ensure_private_portrait_asset_quota_available,
|
||
)
|
||
from app.utils.id_gen import generate_id
|
||
|
||
DOMAIN = "private_portrait"
|
||
|
||
|
||
def _remote_delete_not_found(exc: BaseException) -> bool:
|
||
if isinstance(exc, ArkPrivateAssetRemoteError):
|
||
code = str(exc.code or "").lower()
|
||
return "notfound" in code or code.startswith("not_found")
|
||
message = str(exc).lower()
|
||
return "not found" in message or "notfound" in message
|
||
|
||
PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS = 2
|
||
PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS = 15
|
||
|
||
|
||
def _json(data: Any) -> str | None:
|
||
if data is None:
|
||
return None
|
||
return json.dumps(data, ensure_ascii=False, default=str)
|
||
|
||
|
||
def _loads(data: str | None) -> Any:
|
||
if not data:
|
||
return None
|
||
try:
|
||
return json.loads(data)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _exception_message(exc: Exception) -> str:
|
||
if isinstance(exc, HTTPException):
|
||
detail = exc.detail
|
||
if isinstance(detail, dict):
|
||
message = detail.get("message") or detail.get("detail") or detail
|
||
return str(message)
|
||
return str(detail)
|
||
return str(exc)
|
||
|
||
|
||
def _public_url(url: str) -> str:
|
||
if url.startswith(("http://", "https://", PRIVATE_PORTRAIT_ASSET_URI_PREFIX)):
|
||
return url
|
||
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
|
||
|
||
|
||
def _callback_url(session_id: str, callback_redirect_url: str | None = None) -> str:
|
||
base = f"{settings.BASE_URL.rstrip('/')}/api/private-portrait/validate-callback"
|
||
params = {"session_id": session_id}
|
||
if callback_redirect_url:
|
||
params["redirect_url"] = callback_redirect_url
|
||
return f"{base}?{urlencode(params)}"
|
||
|
||
|
||
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"{user_id}-{safe_name}"[:128]
|
||
|
||
|
||
def _asset_display_url(asset: PrivatePortraitAsset) -> str | None:
|
||
return asset.preview_url or asset.remote_url or asset.source_url or None
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
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 _assert_private_asset_video_duration(payload: PrivatePortraitAssetCreate) -> None:
|
||
if payload.asset_type != PrivatePortraitAssetType.VIDEO.value:
|
||
return
|
||
duration = payload.video_duration
|
||
if duration is None:
|
||
raise HTTPException(status_code=400, detail="Video 素材必须提供 video_duration")
|
||
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
|
||
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒")
|
||
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
|
||
raise HTTPException(status_code=400, detail=f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒")
|
||
|
||
|
||
def _resource_type_for_asset_type(asset_type: str) -> str:
|
||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||
return UploadResourceTypeEnum.VIDEO.value
|
||
return UploadResourceTypeEnum.IMAGE.value
|
||
|
||
|
||
def _safe_set_payload_attr(payload: PrivatePortraitAssetCreate, name: str, value: Any) -> None:
|
||
if value is None:
|
||
return
|
||
try:
|
||
setattr(payload, name, value)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _resolve_upload_resource_for_asset(
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: str,
|
||
payload: PrivatePortraitAssetCreate,
|
||
module: str,
|
||
) -> UploadResource | None:
|
||
"""定位并校验待绑定的 UploadResource。
|
||
|
||
新客户端必须传 upload_resource_id;旧客户端没传时按 url 反查 storage_path 兼容。
|
||
不通过 relationship 懒加载,全部按 ID/路径批查,避免 commit 后 ORM 失效风险。
|
||
"""
|
||
resource_id = str(payload.upload_resource_id or "").strip() or None
|
||
storage_path = upload_url_to_storage_path(payload.url)
|
||
if not resource_id and not storage_path:
|
||
return None
|
||
|
||
filters = [
|
||
UploadResource.user_id == user_id,
|
||
UploadResource.deleted_at.is_(None),
|
||
]
|
||
if resource_id and storage_path:
|
||
filters.append(or_(UploadResource.id == resource_id, UploadResource.storage_path == storage_path))
|
||
elif resource_id:
|
||
filters.append(UploadResource.id == resource_id)
|
||
else:
|
||
filters.append(UploadResource.storage_path == storage_path)
|
||
|
||
result = await db.execute(select(UploadResource).where(*filters).with_for_update().limit(1))
|
||
resource = result.scalar_one_or_none()
|
||
if not resource:
|
||
if resource_id:
|
||
raise HTTPException(status_code=404, detail="上传资源不存在或不属于当前用户")
|
||
return None
|
||
|
||
if resource.bind_status != UploadResourceBindStatusEnum.PENDING.value or resource.source_id or resource.source_model:
|
||
raise HTTPException(status_code=409, detail="上传资源已绑定其他素材,不能重复使用")
|
||
if resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
|
||
raise HTTPException(status_code=409, detail="上传资源当前不允许绑定私域素材")
|
||
|
||
expected_type = _resource_type_for_asset_type(payload.asset_type)
|
||
if resource.resource_type != expected_type:
|
||
raise HTTPException(status_code=400, detail="上传资源类型与素材类型不一致")
|
||
|
||
if resource.module not in {module, UploadResourceModuleEnum.COMMON.value}:
|
||
raise HTTPException(status_code=409, detail="上传资源所属模块不匹配,请重新上传素材")
|
||
|
||
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value and payload.video_duration is None and resource.duration_seconds is not None:
|
||
_safe_set_payload_attr(payload, "video_duration", float(resource.duration_seconds))
|
||
if payload.file_size is None and resource.file_size_bytes is not None:
|
||
_safe_set_payload_attr(payload, "file_size", int(resource.file_size_bytes or 0))
|
||
if payload.mime_type is None and resource.mime_type:
|
||
_safe_set_payload_attr(payload, "mime_type", resource.mime_type)
|
||
|
||
return resource
|
||
|
||
|
||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||
return PrivatePortraitValidateSessionOut(
|
||
id=session.id,
|
||
user_id=session.user_id if include_user else None,
|
||
project_id=session.project_id,
|
||
byted_token=session.byted_token,
|
||
h5_link=session.h5_link,
|
||
callback_url=session.callback_url,
|
||
result_code=session.result_code,
|
||
algorithm_base_resp_code=session.algorithm_base_resp_code,
|
||
verify_type=session.verify_type,
|
||
status=session.status,
|
||
remote_group_id=session.remote_group_id,
|
||
remote_project_name=session.remote_project_name,
|
||
expired_at=session.expired_at,
|
||
error_message=session.error_message,
|
||
created_at=session.created_at,
|
||
updated_at=session.updated_at,
|
||
)
|
||
|
||
|
||
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,
|
||
asset_type=asset.asset_type,
|
||
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,
|
||
next_poll_at=asset.next_poll_at,
|
||
poll_count=asset.poll_count or 0,
|
||
remote_delete_status=asset.remote_delete_status,
|
||
remote_deleted_at=asset.remote_deleted_at,
|
||
remote_delete_error=asset.remote_delete_error,
|
||
error_message=asset.error_message,
|
||
created_at=asset.created_at,
|
||
updated_at=asset.updated_at,
|
||
)
|
||
|
||
|
||
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(*filters)
|
||
.order_by(PrivatePortraitAssetGroup.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
|
||
async def _ensure_project_can_validate(db: AsyncSession, *, project: PrivatePortraitProject) -> PrivatePortraitValidateSession | None:
|
||
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="该真人素材项目已完成认证,不能重复认证")
|
||
|
||
success_session = (
|
||
await db.execute(
|
||
select(PrivatePortraitValidateSession)
|
||
.where(
|
||
PrivatePortraitValidateSession.project_id == project.id,
|
||
PrivatePortraitValidateSession.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value,
|
||
)
|
||
.order_by(PrivatePortraitValidateSession.updated_at.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if success_session:
|
||
raise HTTPException(status_code=409, detail="该真人素材项目已完成认证,不能重复认证")
|
||
|
||
now = datetime.now(timezone.utc)
|
||
pending_session = (
|
||
await db.execute(
|
||
select(PrivatePortraitValidateSession)
|
||
.where(
|
||
PrivatePortraitValidateSession.project_id == project.id,
|
||
PrivatePortraitValidateSession.status.in_([PrivatePortraitValidateSessionStatus.CREATED.value, PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value]),
|
||
PrivatePortraitValidateSession.expired_at.is_not(None),
|
||
PrivatePortraitValidateSession.expired_at > now,
|
||
)
|
||
.order_by(PrivatePortraitValidateSession.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return pending_session
|
||
|
||
|
||
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, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
|
||
reusable_session = await _ensure_project_can_validate(db, project=project)
|
||
if reusable_session:
|
||
return reusable_session
|
||
|
||
project.status = PrivatePortraitProjectStatus.VALIDATING.value
|
||
session = PrivatePortraitValidateSession(
|
||
id=generate_id(),
|
||
user_id=user_id,
|
||
project_id=project.id,
|
||
status=PrivatePortraitValidateSessionStatus.CREATED.value,
|
||
remote_project_name=project.remote_project_name,
|
||
expired_at=datetime.now(timezone.utc) + timedelta(minutes=PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES),
|
||
)
|
||
session.callback_url = _callback_url(session.id, callback_redirect_url)
|
||
db.add(session)
|
||
await db.flush()
|
||
try:
|
||
resp = await ArkPrivateAssetClient().create_visual_validate_session(project_name=project.remote_project_name, callback_url=session.callback_url)
|
||
session.byted_token = resp.get("BytedToken") or resp.get("bytedToken")
|
||
session.h5_link = resp.get("H5Link") or resp.get("h5Link")
|
||
session.raw_response_json = _json(resp)
|
||
await db.flush()
|
||
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, "library_type": project.library_type})
|
||
return session
|
||
except Exception as exc:
|
||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||
session.error_message = _exception_message(exc)
|
||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||
await db.flush()
|
||
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:
|
||
filters.append(PrivatePortraitValidateSession.user_id == user_id)
|
||
session = (await db.execute(select(PrivatePortraitValidateSession).where(*filters).limit(1))).scalar_one_or_none()
|
||
if not session:
|
||
raise HTTPException(status_code=404, detail="真人认证会话不存在")
|
||
return session
|
||
|
||
|
||
async def handle_validate_callback(db: AsyncSession, *, session_id: str, query_params: dict[str, Any]) -> PrivatePortraitValidateSession:
|
||
session = await get_validate_session(db, user_id=None, session_id=session_id)
|
||
if session.status == PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value:
|
||
return session
|
||
|
||
session.raw_callback_json = _json(query_params)
|
||
session.result_code = str(query_params.get("resultCode") or query_params.get("result_code") or "") or None
|
||
session.algorithm_base_resp_code = str(query_params.get("algorithmBaseRespCode") or query_params.get("algorithm_base_resp_code") or "") or None
|
||
session.verify_type = str(query_params.get("verify_type") or query_params.get("verifyType") or "") or None
|
||
token = query_params.get("bytedToken") or query_params.get("byted_token") or session.byted_token
|
||
if token:
|
||
session.byted_token = str(token)
|
||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_RECEIVED.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"query_params": query_params, "remote_project_name": session.remote_project_name})
|
||
|
||
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one_or_none()
|
||
|
||
if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE:
|
||
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value
|
||
session.error_message = f"真人认证失败:resultCode={session.result_code}"
|
||
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||
await db.flush()
|
||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_CALLBACK_FAILED.value, event_status=PrivatePortraitEventStatus.FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, error=session.error_message)
|
||
return session
|
||
|
||
session.status = PrivatePortraitValidateSessionStatus.CALLBACK_SUCCESS.value
|
||
if not session.byted_token:
|
||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||
session.error_message = "Callback 未返回 BytedToken"
|
||
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||
await db.flush()
|
||
raise HTTPException(status_code=400, detail=session.error_message)
|
||
|
||
try:
|
||
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
|
||
if project:
|
||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||
await db.flush()
|
||
await db.refresh(session)
|
||
return session
|
||
|
||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, detail={"remote_project_name": session.remote_project_name})
|
||
resp = await ArkPrivateAssetClient().get_visual_validate_result(project_name=session.remote_project_name, byted_token=session.byted_token)
|
||
group_id = resp.get("GroupId") or resp.get("groupId")
|
||
if not group_id:
|
||
raise RuntimeError("GetVisualValidateResult 未返回 GroupId")
|
||
session.remote_group_id = group_id
|
||
session.status = PrivatePortraitValidateSessionStatus.GROUP_ACTIVE.value
|
||
session.raw_response_json = _json(resp)
|
||
|
||
if not project:
|
||
raise RuntimeError("真人素材项目不存在")
|
||
remote_group_name = _remote_group_name(session.user_id, project.name)
|
||
group = PrivatePortraitAssetGroup(
|
||
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_REAL_PERSON_GROUP_TYPE,
|
||
status=PrivatePortraitAssetGroupStatus.ACTIVE.value,
|
||
raw_response_json=_json(resp),
|
||
)
|
||
db.add(group)
|
||
project.status = PrivatePortraitProjectStatus.ACTIVE.value
|
||
await db.flush()
|
||
try:
|
||
await ArkPrivateAssetClient().update_asset_group(project_name=session.remote_project_name, group_id=group_id, name=remote_group_name, title=remote_group_name, description=project.description)
|
||
except Exception as exc:
|
||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_GROUP_UPDATE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CALLBACK.value, user_id=session.user_id, project_id=session.project_id, session_id=session.id, group_id=group.id, exc=exc)
|
||
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, "library_type": PrivatePortraitLibraryType.REAL_PERSON.value})
|
||
return session
|
||
except Exception as exc:
|
||
session.status = PrivatePortraitValidateSessionStatus.FAILED.value
|
||
session.error_message = _exception_message(exc)
|
||
if project and project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||
project.status = PrivatePortraitProjectStatus.VALIDATE_FAILED.value
|
||
await db.flush()
|
||
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, 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:
|
||
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="项目没有可用的远程素材组")
|
||
return group
|
||
|
||
|
||
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="项目未激活,不能上传素材")
|
||
|
||
module = private_portrait_upload_module(project.library_type)
|
||
upload_resource = await _resolve_upload_resource_for_asset(db, user_id=user_id, payload=payload, module=module)
|
||
_assert_private_asset_video_duration(payload)
|
||
|
||
limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type)
|
||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
|
||
public_url = _public_url(payload.url)
|
||
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={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name, "upload_resource_id": upload_resource.id if upload_resource else payload.upload_resource_id})
|
||
try:
|
||
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
||
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
||
if not remote_asset_id:
|
||
raise RuntimeError("CreateAsset 未返回素材 ID")
|
||
now = datetime.now(timezone.utc)
|
||
asset.remote_asset_id = remote_asset_id
|
||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||
asset.raw_response_json = _json(remote_resp)
|
||
bind_stats = await bind_upload_resources(
|
||
db,
|
||
user_id=user_id,
|
||
module=module,
|
||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||
source_id=asset.id,
|
||
resource_ids=[payload.upload_resource_id, upload_resource.id if upload_resource else None],
|
||
urls=[payload.url],
|
||
allow_common_migrate=True,
|
||
)
|
||
if bind_stats.get("bound"):
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_BIND_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||
source=PrivatePortraitEventSource.API.value,
|
||
user_id=user_id,
|
||
project_id=project.id,
|
||
group_id=group.id,
|
||
asset_id=asset.id,
|
||
detail={"module": module, "upload_resource_id": payload.upload_resource_id, "bind_stats": bind_stats},
|
||
)
|
||
await refresh_project_counters(db, [project.id])
|
||
await db.flush()
|
||
await db.refresh(asset)
|
||
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
|
||
asset.error_message = _exception_message(exc)
|
||
await db.flush()
|
||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc, detail={"upload_resource_id": payload.upload_resource_id, "module": module})
|
||
raise
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PrivatePortraitAssetPollSnapshot:
|
||
id: str
|
||
user_id: str
|
||
project_id: str
|
||
remote_asset_id: str
|
||
remote_project_name: str
|
||
library_type: str
|
||
asset_type: str
|
||
status: str
|
||
poll_count: int
|
||
|
||
|
||
async def _load_asset_poll_snapshot(
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: str | None,
|
||
asset_id: str,
|
||
) -> PrivatePortraitAssetPollSnapshot:
|
||
filters = [PrivatePortraitAsset.id == asset_id]
|
||
if user_id is not None:
|
||
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="私域人像素材不存在")
|
||
if asset.deleted_at is not None:
|
||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||
if not asset.remote_asset_id:
|
||
raise HTTPException(status_code=400, detail="私域人像素材尚未创建远程 Asset")
|
||
return PrivatePortraitAssetPollSnapshot(
|
||
id=str(asset.id),
|
||
user_id=str(asset.user_id),
|
||
project_id=str(asset.project_id),
|
||
remote_asset_id=str(asset.remote_asset_id),
|
||
remote_project_name=str(asset.remote_project_name or ""),
|
||
library_type=str(asset.library_type or ""),
|
||
asset_type=str(asset.asset_type or ""),
|
||
status=str(asset.status or ""),
|
||
poll_count=int(asset.poll_count or 0),
|
||
)
|
||
|
||
|
||
async def _apply_asset_poll_response(
|
||
db: AsyncSession,
|
||
*,
|
||
snapshot: PrivatePortraitAssetPollSnapshot,
|
||
remote_resp: dict[str, Any],
|
||
source: str,
|
||
) -> PrivatePortraitAsset:
|
||
asset = (
|
||
await db.execute(
|
||
select(PrivatePortraitAsset)
|
||
.where(PrivatePortraitAsset.id == snapshot.id)
|
||
.with_for_update()
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not asset:
|
||
raise HTTPException(status_code=404, detail="私域人像素材不存在")
|
||
if asset.deleted_at is not None:
|
||
raise HTTPException(status_code=400, detail="私域人像素材已删除")
|
||
if str(asset.remote_asset_id or "") != snapshot.remote_asset_id:
|
||
raise RuntimeError("私域素材远程 Asset 已变化,旧轮询结果已丢弃")
|
||
if int(asset.poll_count or 0) != snapshot.poll_count:
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_SYNC_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||
source=source,
|
||
user_id=asset.user_id,
|
||
project_id=asset.project_id,
|
||
asset_id=asset.id,
|
||
message="检测到更新的轮询结果,当前旧结果已丢弃",
|
||
detail={
|
||
"snapshot_poll_count": snapshot.poll_count,
|
||
"current_poll_count": int(asset.poll_count or 0),
|
||
},
|
||
)
|
||
return asset
|
||
|
||
status = remote_resp.get("Status") or remote_resp.get("status")
|
||
now = datetime.now(timezone.utc)
|
||
asset.last_poll_at = now
|
||
asset.poll_count = snapshot.poll_count + 1
|
||
asset.raw_response_json = _json(remote_resp)
|
||
if status:
|
||
asset.status = str(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 >= 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": 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=_poll_interval_seconds(asset.asset_type))
|
||
else:
|
||
asset.next_poll_at = None
|
||
|
||
if asset.status == PrivatePortraitAssetStatus.FAILED.value and not asset.error_message:
|
||
asset.error_message = (
|
||
remote_resp.get("ErrorMessage")
|
||
or remote_resp.get("error_message")
|
||
or "素材入库失败"
|
||
)
|
||
await refresh_project_counters(db, [asset.project_id])
|
||
await db.flush()
|
||
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
|
||
|
||
|
||
async def sync_asset_status(
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: str | None,
|
||
asset_id: str,
|
||
execution_guard: Callable[[], Awaitable[None]] | None = None,
|
||
) -> PrivatePortraitAsset:
|
||
snapshot = await _load_asset_poll_snapshot(db, user_id=user_id, asset_id=asset_id)
|
||
source = (
|
||
PrivatePortraitEventSource.CELERY.value
|
||
if user_id is None
|
||
else PrivatePortraitEventSource.API.value
|
||
)
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_SYNC_START.value,
|
||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||
source=source,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.id,
|
||
detail={
|
||
"status": snapshot.status,
|
||
"poll_count": snapshot.poll_count,
|
||
"remote_asset_id": snapshot.remote_asset_id,
|
||
"remote_project_name": snapshot.remote_project_name,
|
||
"library_type": snapshot.library_type,
|
||
"asset_type": snapshot.asset_type,
|
||
},
|
||
)
|
||
# 远程调用期间不能持有数据库事务,避免 Celery 长请求形成 idle in transaction。
|
||
await db.rollback()
|
||
try:
|
||
remote_resp = await ArkPrivateAssetClient(for_celery=(user_id is None)).get_asset(
|
||
project_name=snapshot.remote_project_name,
|
||
asset_id=snapshot.remote_asset_id,
|
||
)
|
||
if execution_guard is not None:
|
||
await execution_guard()
|
||
return await _apply_asset_poll_response(
|
||
db,
|
||
snapshot=snapshot,
|
||
remote_resp=remote_resp,
|
||
source=source,
|
||
)
|
||
except Exception as exc:
|
||
log_operation_error(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_SYNC_FAILED.value,
|
||
source=source,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.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,
|
||
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)]
|
||
if user_id:
|
||
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:
|
||
filters.append(PrivatePortraitAsset.name.ilike(f"%{keyword.strip()}%"))
|
||
total = (await db.execute(select(func.count(PrivatePortraitAsset.id)).where(*filters))).scalar_one()
|
||
result = await db.execute(select(PrivatePortraitAsset).where(*filters).order_by(PrivatePortraitAsset.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
|
||
assets = list(result.scalars().all())
|
||
project_ids = list({asset.project_id for asset in assets})
|
||
project_name_map: dict[str, str] = {}
|
||
if project_ids:
|
||
rows = await db.execute(select(PrivatePortraitProject.id, PrivatePortraitProject.name).where(PrivatePortraitProject.id.in_(project_ids)))
|
||
project_name_map = {pid: name for pid, name in rows.all()}
|
||
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, 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, 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="私域人像素材不存在")
|
||
now = datetime.now(timezone.utc)
|
||
asset.deleted_at = now
|
||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
|
||
module = private_portrait_upload_module(asset.library_type)
|
||
upload_release = await release_upload_resources_by_source(
|
||
db,
|
||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||
source_ids=[asset.id],
|
||
module=module,
|
||
)
|
||
setattr(asset, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or []))
|
||
setattr(asset, "_upload_resource_release", upload_release)
|
||
await refresh_project_counters(db, [asset.project_id])
|
||
await db.flush()
|
||
await db.refresh(asset)
|
||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
|
||
return asset
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class PrivatePortraitRemoteDeleteSnapshot:
|
||
owner_id: str
|
||
owner_type: str
|
||
user_id: str
|
||
project_id: str
|
||
remote_id: str | None
|
||
remote_project_name: str
|
||
library_type: str
|
||
asset_type: str | None = None
|
||
|
||
|
||
async def _load_asset_delete_snapshot(
|
||
db: AsyncSession,
|
||
*,
|
||
asset_id: str,
|
||
) -> PrivatePortraitRemoteDeleteSnapshot | None:
|
||
asset = (
|
||
await db.execute(
|
||
select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not asset:
|
||
return None
|
||
if asset.remote_delete_status in {
|
||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
|
||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
|
||
}:
|
||
return PrivatePortraitRemoteDeleteSnapshot(
|
||
owner_id=str(asset.id),
|
||
owner_type="asset_terminal",
|
||
user_id=str(asset.user_id),
|
||
project_id=str(asset.project_id),
|
||
remote_id=str(asset.remote_asset_id) if asset.remote_asset_id else None,
|
||
remote_project_name=str(asset.remote_project_name or ""),
|
||
library_type=str(asset.library_type or ""),
|
||
asset_type=str(asset.asset_type or ""),
|
||
)
|
||
return PrivatePortraitRemoteDeleteSnapshot(
|
||
owner_id=str(asset.id),
|
||
owner_type="asset",
|
||
user_id=str(asset.user_id),
|
||
project_id=str(asset.project_id),
|
||
remote_id=str(asset.remote_asset_id) if asset.remote_asset_id else None,
|
||
remote_project_name=str(asset.remote_project_name or ""),
|
||
library_type=str(asset.library_type or ""),
|
||
asset_type=str(asset.asset_type or ""),
|
||
)
|
||
|
||
|
||
async def _apply_asset_delete_result(
|
||
db: AsyncSession,
|
||
*,
|
||
snapshot: PrivatePortraitRemoteDeleteSnapshot,
|
||
succeeded: bool,
|
||
skipped: bool = False,
|
||
error: BaseException | None = None,
|
||
) -> None:
|
||
asset = (
|
||
await db.execute(
|
||
select(PrivatePortraitAsset)
|
||
.where(PrivatePortraitAsset.id == snapshot.owner_id)
|
||
.with_for_update()
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not asset:
|
||
return
|
||
if asset.remote_delete_status in {
|
||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
|
||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
|
||
}:
|
||
return
|
||
if str(asset.remote_asset_id or "") != str(snapshot.remote_id or ""):
|
||
raise RuntimeError("私域素材远程 Asset 已变化,旧删除结果已丢弃")
|
||
now = datetime.now(timezone.utc)
|
||
if skipped:
|
||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||
asset.remote_delete_error = None
|
||
elif succeeded:
|
||
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||
asset.remote_deleted_at = now
|
||
asset.remote_delete_error = None
|
||
else:
|
||
asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value
|
||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||
asset.remote_delete_error = str(error or "远程删除失败")
|
||
await db.flush()
|
||
|
||
|
||
async def delete_asset_remote(
|
||
db: AsyncSession,
|
||
*,
|
||
asset_id: str,
|
||
execution_guard: Callable[[], Awaitable[None]] | None = None,
|
||
) -> None:
|
||
snapshot = await _load_asset_delete_snapshot(db, asset_id=asset_id)
|
||
if snapshot is None:
|
||
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="远程删除跳过:本地素材不存在",
|
||
)
|
||
await db.rollback()
|
||
return
|
||
if snapshot.owner_type == "asset_terminal":
|
||
await db.rollback()
|
||
return
|
||
if not snapshot.remote_id:
|
||
await _apply_asset_delete_result(db, snapshot=snapshot, succeeded=False, skipped=True)
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.owner_id,
|
||
message="远程删除跳过:素材没有 remote_asset_id",
|
||
)
|
||
return
|
||
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
|
||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.owner_id,
|
||
detail={
|
||
"remote_asset_id": snapshot.remote_id,
|
||
"remote_project_name": snapshot.remote_project_name,
|
||
"library_type": snapshot.library_type,
|
||
"asset_type": snapshot.asset_type,
|
||
},
|
||
)
|
||
await db.rollback()
|
||
remote_error: BaseException | None = None
|
||
succeeded = False
|
||
try:
|
||
await ArkPrivateAssetClient(for_celery=True).delete_asset(
|
||
project_name=snapshot.remote_project_name,
|
||
asset_id=snapshot.remote_id,
|
||
)
|
||
succeeded = True
|
||
except Exception as exc:
|
||
remote_error = exc
|
||
succeeded = _remote_delete_not_found(exc)
|
||
if execution_guard is not None:
|
||
await execution_guard()
|
||
await _apply_asset_delete_result(
|
||
db,
|
||
snapshot=snapshot,
|
||
succeeded=succeeded,
|
||
error=remote_error,
|
||
)
|
||
if succeeded:
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.owner_id,
|
||
message=(
|
||
"远程资源不存在,按幂等删除成功处理"
|
||
if remote_error is not None
|
||
else None
|
||
),
|
||
detail={
|
||
"remote_asset_id": snapshot.remote_id,
|
||
"remote_project_name": snapshot.remote_project_name,
|
||
"library_type": snapshot.library_type,
|
||
},
|
||
)
|
||
else:
|
||
assert remote_error is not None
|
||
log_operation_error(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
asset_id=snapshot.owner_id,
|
||
exc=remote_error,
|
||
)
|
||
|
||
|
||
async def _load_group_delete_snapshot(
|
||
db: AsyncSession,
|
||
*,
|
||
group_id: str,
|
||
) -> PrivatePortraitRemoteDeleteSnapshot | None:
|
||
group = (
|
||
await db.execute(
|
||
select(PrivatePortraitAssetGroup)
|
||
.where(PrivatePortraitAssetGroup.id == group_id)
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not group:
|
||
return None
|
||
owner_type = (
|
||
"group_terminal"
|
||
if group.remote_delete_status
|
||
in {
|
||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
|
||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
|
||
}
|
||
else "group"
|
||
)
|
||
return PrivatePortraitRemoteDeleteSnapshot(
|
||
owner_id=str(group.id),
|
||
owner_type=owner_type,
|
||
user_id=str(group.user_id),
|
||
project_id=str(group.project_id),
|
||
remote_id=str(group.remote_group_id) if group.remote_group_id else None,
|
||
remote_project_name=str(group.remote_project_name or ""),
|
||
library_type=str(group.library_type or ""),
|
||
)
|
||
|
||
|
||
async def _apply_group_delete_result(
|
||
db: AsyncSession,
|
||
*,
|
||
snapshot: PrivatePortraitRemoteDeleteSnapshot,
|
||
succeeded: bool,
|
||
skipped: bool = False,
|
||
error: BaseException | None = None,
|
||
) -> None:
|
||
group = (
|
||
await db.execute(
|
||
select(PrivatePortraitAssetGroup)
|
||
.where(PrivatePortraitAssetGroup.id == snapshot.owner_id)
|
||
.with_for_update()
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not group:
|
||
return
|
||
if group.remote_delete_status in {
|
||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
|
||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
|
||
}:
|
||
return
|
||
if str(group.remote_group_id or "") != str(snapshot.remote_id or ""):
|
||
raise RuntimeError("私域素材组远程 ID 已变化,旧删除结果已丢弃")
|
||
now = datetime.now(timezone.utc)
|
||
if skipped:
|
||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
|
||
group.remote_delete_error = None
|
||
elif succeeded:
|
||
group.status = PrivatePortraitAssetGroupStatus.REMOTE_DELETED.value
|
||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
|
||
group.remote_deleted_at = now
|
||
group.remote_delete_error = None
|
||
else:
|
||
group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value
|
||
group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
|
||
group.remote_delete_error = str(error or "远程删除失败")
|
||
await db.flush()
|
||
|
||
|
||
async def delete_asset_group_remote(
|
||
db: AsyncSession,
|
||
*,
|
||
group_id: str,
|
||
execution_guard: Callable[[], Awaitable[None]] | None = None,
|
||
) -> None:
|
||
snapshot = await _load_group_delete_snapshot(db, group_id=group_id)
|
||
if snapshot is None:
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
group_id=group_id,
|
||
message="远程删除跳过:本地素材组不存在",
|
||
)
|
||
await db.rollback()
|
||
return
|
||
if snapshot.owner_type == "group_terminal":
|
||
await db.rollback()
|
||
return
|
||
if not snapshot.remote_id:
|
||
await _apply_group_delete_result(db, snapshot=snapshot, succeeded=False, skipped=True)
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
group_id=snapshot.owner_id,
|
||
message="远程删除跳过:素材组没有 remote_group_id",
|
||
)
|
||
return
|
||
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
|
||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
group_id=snapshot.owner_id,
|
||
detail={
|
||
"remote_group_id": snapshot.remote_id,
|
||
"remote_project_name": snapshot.remote_project_name,
|
||
"library_type": snapshot.library_type,
|
||
},
|
||
)
|
||
await db.rollback()
|
||
remote_error: BaseException | None = None
|
||
succeeded = False
|
||
try:
|
||
await ArkPrivateAssetClient(for_celery=True).delete_asset_group(
|
||
project_name=snapshot.remote_project_name,
|
||
group_id=snapshot.remote_id,
|
||
)
|
||
succeeded = True
|
||
except Exception as exc:
|
||
remote_error = exc
|
||
succeeded = _remote_delete_not_found(exc)
|
||
if execution_guard is not None:
|
||
await execution_guard()
|
||
await _apply_group_delete_result(
|
||
db,
|
||
snapshot=snapshot,
|
||
succeeded=succeeded,
|
||
error=remote_error,
|
||
)
|
||
if succeeded:
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
group_id=snapshot.owner_id,
|
||
message=(
|
||
"远程素材组不存在,按幂等删除成功处理"
|
||
if remote_error is not None
|
||
else None
|
||
),
|
||
detail={
|
||
"remote_group_id": snapshot.remote_id,
|
||
"remote_project_name": snapshot.remote_project_name,
|
||
"library_type": snapshot.library_type,
|
||
},
|
||
)
|
||
else:
|
||
assert remote_error is not None
|
||
log_operation_error(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
user_id=snapshot.user_id,
|
||
project_id=snapshot.project_id,
|
||
group_id=snapshot.owner_id,
|
||
exc=remote_error,
|
||
)
|