from __future__ import annotations import json from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import urlencode from fastapi import HTTPException from sqlalchemy import and_, func, select, update 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_SUCCESS_RESULT_CODE, PRIVATE_PORTRAIT_VALIDATE_TOKEN_EXPIRE_MINUTES, PrivatePortraitAssetGroupStatus, PrivatePortraitAssetStatus, PrivatePortraitAssetType, PrivatePortraitEventSource, PrivatePortraitEventStatus, PrivatePortraitEventType, 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.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.utils.id_gen import generate_id DOMAIN = "private_portrait" 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 _public_url(url: str) -> str: if url.startswith(("http://", "https://")): 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] 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) 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 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) 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 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: 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, 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, remote_url=asset.remote_url, remote_url_expired_at=asset.remote_url_expired_at, 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 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) 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() 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}) return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.error_message = str(exc) await db.flush() log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.VALIDATE_GET_RESULT_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) 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}) if session.result_code != PRIVATE_PORTRAIT_SUCCESS_RESULT_CODE: session.status = PrivatePortraitValidateSessionStatus.CALLBACK_FAILED.value session.error_message = f"真人认证失败:resultCode={session.result_code}" 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" await db.flush() raise HTTPException(status_code=400, detail=session.error_message) try: 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) project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == session.project_id).limit(1))).scalar_one() 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, remote_group_id=group_id, remote_group_name=remote_group_name, remote_project_name=session.remote_project_name, group_type=PRIVATE_PORTRAIT_GROUP_TYPE, status=PrivatePortraitAssetGroupStatus.ACTIVE.value, raw_response_json=_json(resp), ) db.add(group) 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]) 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}) return session except Exception as exc: session.status = PrivatePortraitValidateSessionStatus.FAILED.value session.error_message = str(exc) 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) -> PrivatePortraitAssetGroup: 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)) 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) -> PrivatePortraitAsset: project = await get_user_project(db, user_id=user_id, project_id=project_id) 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} 张图片,请删除已有素材后再上传") group = await get_project_active_group(db, user_id=user_id, project_id=project.id) public_url = _public_url(payload.url) asset = PrivatePortraitAsset( id=generate_id(), user_id=user_id, project_id=project.id, group_id=group.id, 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, 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}) 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=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) asset.raw_response_json = _json(remote_resp) await refresh_project_counters(db, [project.id]) await db.flush() 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}) return asset except Exception as exc: asset.status = PrivatePortraitAssetStatus.FAILED.value asset.error_message = str(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) raise async def sync_asset_status(db: AsyncSession, *, user_id: str | None, asset_id: str) -> PrivatePortraitAsset: 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") 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=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, }, ) 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) status = remote_resp.get("Status") or remote_resp.get("status") now = datetime.now(timezone.utc) asset.last_poll_at = now asset.poll_count = int(asset.poll_count or 0) + 1 asset.raw_response_json = _json(remote_resp) if status: 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")) if asset.status == PrivatePortraitAssetStatus.PROCESSING.value and asset.poll_count >= PRIVATE_PORTRAIT_ASSET_POLL_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, ) elif asset.status == PrivatePortraitAssetStatus.PROCESSING.value: asset.next_poll_at = now + timedelta(seconds=PRIVATE_PORTRAIT_ASSET_POLL_INTERVAL_SECONDS) 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}, ) 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]]: 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 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) -> 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 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() 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 await refresh_project_counters(db, [asset.project_id]) await db.flush() 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}) 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="远程删除跳过:本地素材不存在", ) 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", ) 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}, ) 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}, ) except Exception as exc: asset.status = PrivatePortraitAssetStatus.DELETE_FAILED.value asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value asset.remote_delete_error = str(exc) log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, exc=exc) await db.flush() async def _delete_asset_group_remote(db: AsyncSession, *, group: PrivatePortraitAssetGroup, client: ArkPrivateAssetClient | None = None) -> None: if not group.remote_group_id: 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", ) 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}, ) 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}, ) except Exception as exc: group.status = PrivatePortraitAssetGroupStatus.DELETE_FAILED.value group.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value group.remote_delete_error = str(exc) log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_FAILED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=group.user_id, project_id=group.project_id, group_id=group.id, exc=exc) await db.flush() 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="开始远程删除真人素材项目资源", ) 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) groups = await db.execute(select(PrivatePortraitAssetGroup).where(PrivatePortraitAssetGroup.project_id == project_id)) 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="远程删除真人素材项目资源完成", ) await db.flush() async def poll_due_assets_once(db: AsyncSession, *, limit: int) -> int: now = datetime.now(timezone.utc) rows = await db.execute( select(PrivatePortraitAsset.id) .where( PrivatePortraitAsset.deleted_at.is_(None), PrivatePortraitAsset.status == PrivatePortraitAssetStatus.PROCESSING.value, PrivatePortraitAsset.next_poll_at.is_not(None), PrivatePortraitAsset.next_poll_at <= now, ) .order_by(PrivatePortraitAsset.next_poll_at.asc()) .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)}, ) success_count = 0 failed_count = 0 for asset_id in ids: try: await sync_asset_status(db, user_id=None, asset_id=asset_id) 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}, ) 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}, ) 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_ids = [row[0] for row in asset_rows.all()] for asset_id in asset_ids: await delete_asset_remote(db, asset_id=asset_id) 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) ) client = ArkPrivateAssetClient(for_celery=True) groups = list(group_rows.scalars().all()) group_count = len(groups) for group in groups: 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, ) return result