675 lines
26 KiB
Python
675 lines
26 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Awaitable, Callable
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.enums.private_portrait import (
|
||
PrivatePortraitAssetStatus,
|
||
PrivatePortraitAssetType,
|
||
PrivatePortraitEventSource,
|
||
PrivatePortraitEventStatus,
|
||
PrivatePortraitEventType,
|
||
PrivatePortraitProjectStatus,
|
||
PrivatePortraitRemoteDeleteStatus,
|
||
)
|
||
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
|
||
from app.schemas.virtual_portrait_v3.asset import (
|
||
VpV3AssetCreate,
|
||
VpV3AssetListOut,
|
||
VpV3AssetOut,
|
||
VpV3SelectableAssetListOut,
|
||
VpV3SelectableAssetOut,
|
||
)
|
||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||
from app.services.private_portrait.ark_client import (
|
||
ArkPrivateAssetClient,
|
||
ArkPrivateAssetClientError,
|
||
)
|
||
from app.services.virtual_portrait_v3.project_service import (
|
||
refresh_project_counters,
|
||
)
|
||
from app.services.virtual_portrait_v3.quota_service import (
|
||
_bytes_to_mb,
|
||
_refresh_quota_used,
|
||
check_asset_quota,
|
||
get_quota,
|
||
remote_project_name,
|
||
)
|
||
from app.services.virtual_portrait_v3.upload_service import (
|
||
delete_local_file_by_url,
|
||
download_url_to_local,
|
||
)
|
||
from app.utils.id_gen import generate_id
|
||
|
||
logger = logging.getLogger("videogen")
|
||
DOMAIN = "virtual_portrait_v3"
|
||
|
||
URL_RE_REMOTE_URL_EXPR = re.compile(r"^https?://", re.IGNORECASE)
|
||
URL_LOCAL_UPLOAD_EXPR = re.compile(r"^/uploads/|^https?://[^/]+/uploads/", re.IGNORECASE)
|
||
|
||
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
|
||
_BJ_TZ = timezone(timedelta(hours=8))
|
||
|
||
|
||
def _bj_now() -> datetime:
|
||
"""返回当前北京时间(UTC+8)naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
|
||
return datetime.now(_BJ_TZ).replace(tzinfo=None)
|
||
|
||
|
||
def _json(data) -> str | None:
|
||
if data is None:
|
||
return None
|
||
return json.dumps(data, ensure_ascii=False, default=str)
|
||
|
||
|
||
def asset_to_out(a: VpV3Asset) -> VpV3AssetOut:
|
||
|
||
return VpV3AssetOut(
|
||
asset_id=a.id,
|
||
project_id=a.project_id,
|
||
name=a.name,
|
||
asset_type=a.asset_type,
|
||
status=a.status,
|
||
source_url=a.source_url,
|
||
preview_url=a.preview_url,
|
||
remote_url=a.remote_url,
|
||
remote_url_expired_at=a.remote_url_expired_at,
|
||
video_duration=a.video_duration,
|
||
video_cover_url=a.video_cover_url,
|
||
file_size_bytes=a.file_size_bytes,
|
||
mime_type=a.mime_type,
|
||
moderation_json=a.moderation_json,
|
||
error_message=a.error_message,
|
||
remote_delete_status=a.remote_delete_status or PrivatePortraitRemoteDeleteStatus.NONE.value,
|
||
created_at=a.created_at,
|
||
updated_at=a.updated_at,
|
||
)
|
||
|
||
|
||
def asset_to_selectable(a: VpV3Asset) -> VpV3SelectableAssetOut:
|
||
return VpV3SelectableAssetOut(
|
||
asset_id=a.id,
|
||
project_id=a.project_id,
|
||
name=a.name,
|
||
asset_type=a.asset_type,
|
||
status=a.status,
|
||
source_url=a.source_url,
|
||
preview_url=a.preview_url or a.remote_url or a.source_url,
|
||
video_duration=a.video_duration,
|
||
video_cover_url=a.video_cover_url,
|
||
file_size_bytes=a.file_size_bytes,
|
||
created_at=a.created_at,
|
||
)
|
||
|
||
|
||
def _validate_source_url(url: str, asset_type: str) -> None:
|
||
"""创建素材时的 source_url 现在只允许 http(s) 的外部 URL。
|
||
旧的 /uploads/* 本地 URL 已不再推荐(直接让系统自己下载保存)。"""
|
||
if not url or not url.strip():
|
||
raise HTTPException(status_code=400, detail="source_url 不能为空")
|
||
stripped = url.strip()
|
||
if not stripped.lower().startswith("http://") and not stripped.lower().startswith("https://"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="source_url 必须是公网可访问的 http(s) URL;本服务会自动下载并保存到本地",
|
||
)
|
||
if len(stripped) > 2000:
|
||
raise HTTPException(status_code=400, detail="source_url 过长(最多 2000 字符)")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Asset CRUD
|
||
# ---------------------------------------------------------------------------
|
||
|
||
async def create_asset(
|
||
db: AsyncSession,
|
||
*,
|
||
api_key_id: str,
|
||
project: VpV3Project,
|
||
payload: VpV3AssetCreate,
|
||
) -> VpV3Asset:
|
||
"""在项目下创建素材:
|
||
|
||
**新流程(一步到位)**:
|
||
1. project 状态校验
|
||
2. source_url 格式校验
|
||
3. 将 source_url 下载保存到本地 vp_v3 上传目录(占用磁盘,校验 MIME/大小/网络)
|
||
- 失败:抛 HTTPException(400/413/415/502/500),不留临时文件
|
||
4. 配额校验(素材数 + 存储 MB,用下载后的实际 file_size_bytes)
|
||
- 失败:**立刻删除本地已下载的文件**,避免占用磁盘;再抛 403
|
||
5. Video 时长校验(payload.video_duration 优先,否则用 ffprobe 探测到的值;>60s 报错)
|
||
- 失败:删本地文件 → 抛 400
|
||
6. 写 VpV3Asset(Creating 状态,带 next_poll_at)
|
||
- 失败:删本地文件 → 抛 500
|
||
7. 调 Ark CreateAsset(url=本地公网 URL),异步审核
|
||
- 异常:status 置为 FAILED,保留本地文件(因为已占配额和素材数,走删除接口会清理)
|
||
8. 刷新项目计数 + 配额 used,返回素材
|
||
"""
|
||
# 1. project 状态校验
|
||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||
raise HTTPException(status_code=400, detail=f"项目状态 {project.status} 不可创建素材,仅 active 项目可操作")
|
||
|
||
# 2. source_url 校验(只允许公网 http(s))
|
||
_validate_source_url(payload.source_url, payload.asset_type)
|
||
|
||
downloaded: "DownloadedAsset | None" = None
|
||
try:
|
||
# 3. URL → 本地下载保存(此处负责 URL 合法性/网络/MIME/大小的校验及抛错)
|
||
downloaded = await download_url_to_local(
|
||
api_key_id=api_key_id,
|
||
asset_type=payload.asset_type,
|
||
source_url=payload.source_url,
|
||
requested_filename=payload.name,
|
||
)
|
||
file_size_bytes = downloaded.file_size_bytes
|
||
|
||
# 4. 配额校验(素材数 + 存储),这里已经拿到真实 file_size_bytes
|
||
try:
|
||
await check_asset_quota(
|
||
db,
|
||
api_key_id=api_key_id,
|
||
asset_count_delta=1,
|
||
file_size_bytes=file_size_bytes,
|
||
)
|
||
except HTTPException:
|
||
# 配额不足 → 立刻清理刚下载好的本地文件,再抛
|
||
_safe_delete_local_file(downloaded.url)
|
||
raise
|
||
|
||
# 5. Video 时长:优先用 payload.video_duration,否则用探测值
|
||
effective_video_duration: float | None = None
|
||
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||
if payload.video_duration is not None and payload.video_duration > 0:
|
||
effective_video_duration = float(payload.video_duration)
|
||
elif downloaded.duration_seconds is not None and downloaded.duration_seconds > 0:
|
||
effective_video_duration = float(downloaded.duration_seconds)
|
||
else:
|
||
_safe_delete_local_file(downloaded.url)
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Video 素材无法获取时长:请显式传 video_duration(秒),或确保 URL 指向合法的视频文件",
|
||
)
|
||
if effective_video_duration > 60:
|
||
_safe_delete_local_file(downloaded.url)
|
||
raise HTTPException(status_code=400, detail="视频素材时长不能超过 60 秒")
|
||
|
||
# 素材展示名:payload.name → downloaded.suggested_name → filename 去扩展名
|
||
final_name: str | None = (payload.name or "").strip()[:128] or None
|
||
if not final_name and downloaded.suggested_name:
|
||
final_name = (downloaded.suggested_name or "").strip()[:128] or None
|
||
|
||
asset = VpV3Asset(
|
||
id=generate_id(),
|
||
api_key_id=api_key_id,
|
||
project_id=project.id,
|
||
remote_project_name=project.remote_project_name,
|
||
remote_group_id=project.remote_group_id,
|
||
remote_asset_id=None,
|
||
asset_type=payload.asset_type,
|
||
name=final_name,
|
||
source_url=payload.source_url, # 本地存储后的 URL
|
||
preview_url=downloaded.url, # 初始 preview = 本地 URL
|
||
remote_url=None,
|
||
remote_url_expired_at=None,
|
||
upload_resource_id=None, # 不再使用(旧接口兼容保留字段)
|
||
video_duration=effective_video_duration,
|
||
video_cover_url=payload.video_cover_url,
|
||
file_size_bytes=file_size_bytes,
|
||
mime_type=downloaded.mime_type,
|
||
status=PrivatePortraitAssetStatus.CREATING.value,
|
||
poll_count=0,
|
||
next_poll_at=_bj_now() + timedelta(seconds=2),
|
||
)
|
||
db.add(asset)
|
||
await db.flush()
|
||
await db.refresh(asset)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001
|
||
# 任何 DB 写入前的异常 → 能清理就清理本地文件
|
||
if downloaded:
|
||
_safe_delete_local_file(downloaded.url)
|
||
logger.exception("vp_v3 创建素材(下载/写库阶段)异常:%s", exc)
|
||
raise HTTPException(status_code=500, detail=f"创建素材失败:{exc}") from exc
|
||
|
||
# 6. 调 Ark CreateAsset(到这里 DB 已经 flush 成功了)
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_START.value,
|
||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||
source=PrivatePortraitEventSource.API.value,
|
||
api_key_id=api_key_id,
|
||
project_id=project.id,
|
||
asset_id=asset.id,
|
||
detail={
|
||
"remote_project_name": asset.remote_project_name,
|
||
"remote_group_id": asset.remote_group_id,
|
||
"source_url": asset.source_url,
|
||
"asset_type": asset.asset_type,
|
||
"original_source_url": payload.source_url.strip()[:500],
|
||
},
|
||
)
|
||
try:
|
||
resp = await ArkPrivateAssetClient().create_asset(
|
||
project_name=asset.remote_project_name,
|
||
group_id=asset.remote_group_id,
|
||
url=asset.source_url,
|
||
asset_type=asset.asset_type,
|
||
name=asset.name,
|
||
)
|
||
remote_asset_id = resp.get("Id") or resp.get("AssetId") or resp.get("assetId") or resp.get("id")
|
||
if not remote_asset_id:
|
||
raise RuntimeError("CreateAsset 未返回素材 Id")
|
||
asset.remote_asset_id = str(remote_asset_id)
|
||
asset.raw_response_json = _json(resp)
|
||
asset.remote_url = resp.get("URL") or resp.get("url") or resp.get("Url") or asset.remote_url
|
||
if asset.remote_url:
|
||
asset.preview_url = asset.remote_url
|
||
asset.next_poll_at = _bj_now() + timedelta(seconds=3)
|
||
asset.status = PrivatePortraitAssetStatus.CREATING.value
|
||
log_operation_event(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||
source=PrivatePortraitEventSource.API.value,
|
||
api_key_id=api_key_id,
|
||
project_id=project.id,
|
||
asset_id=asset.id,
|
||
detail={"remote_asset_id": remote_asset_id},
|
||
)
|
||
await refresh_project_counters(db, [project.id])
|
||
_ = await get_quota(db, api_key_id=api_key_id, refresh=True)
|
||
return asset
|
||
except Exception as exc: # noqa: BLE001
|
||
# 火山调用失败 → 保留本地文件(DB 已写好,走删除接口清理),状态 FAILED,带错误
|
||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||
asset.error_message = str(exc)
|
||
asset.raw_response_json = _json({"error": str(exc)})
|
||
log_operation_error(
|
||
domain=DOMAIN,
|
||
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_CREATE_REMOTE_FAILED.value,
|
||
source=PrivatePortraitEventSource.API.value,
|
||
api_key_id=api_key_id,
|
||
project_id=project.id,
|
||
asset_id=asset.id,
|
||
exc=exc,
|
||
)
|
||
raise HTTPException(status_code=502, detail=f"提交火山素材创建失败:{exc}") from exc
|
||
|
||
|
||
def _safe_delete_local_file(local_url: str | None) -> None:
|
||
if not local_url:
|
||
return
|
||
try:
|
||
delete_local_file_by_url(local_url)
|
||
except Exception: # noqa: BLE001
|
||
logger.warning("vp_v3 清理本地文件失败(不抛):%s", local_url)
|
||
|
||
|
||
async def list_assets(
|
||
db: AsyncSession,
|
||
*,
|
||
api_key_id: str,
|
||
project_id: str | None = None,
|
||
status: str | None = None,
|
||
keyword: str | None = None,
|
||
asset_type: str | None = None,
|
||
page: int,
|
||
page_size: int,
|
||
) -> tuple[list[VpV3Asset], int]:
|
||
"""分页查询素材列表。"""
|
||
conds = [VpV3Asset.api_key_id == api_key_id, VpV3Asset.deleted_at.is_(None)]
|
||
if project_id:
|
||
conds.append(VpV3Asset.remote_group_id == project_id)
|
||
if status:
|
||
conds.append(VpV3Asset.status == status)
|
||
if keyword:
|
||
conds.append((VpV3Asset.name.is_not(None)) & (VpV3Asset.name.ilike(f"%{keyword}%")))
|
||
if asset_type:
|
||
conds.append(VpV3Asset.asset_type == asset_type)
|
||
count_result = await db.execute(select(func.count(VpV3Asset.id)).where(*conds))
|
||
total = int(count_result.scalar() or 0)
|
||
q = (
|
||
select(VpV3Asset)
|
||
.where(*conds)
|
||
.order_by(VpV3Asset.created_at.desc())
|
||
.limit(page_size)
|
||
.offset((page - 1) * page_size)
|
||
)
|
||
items = list((await db.execute(q)).scalars().all())
|
||
return items, total
|
||
|
||
|
||
async def list_selectable_assets(
|
||
db: AsyncSession,
|
||
*,
|
||
api_key_id: str,
|
||
project_id: str | None = None,
|
||
keyword: str | None = None,
|
||
asset_type: str | None = None,
|
||
page: int,
|
||
page_size: int,
|
||
) -> tuple[list[VpV3Asset], int]:
|
||
"""AI 创作选择器素材列表:只返回 status=Active 的。"""
|
||
items, total = await list_assets(
|
||
db,
|
||
api_key_id=api_key_id,
|
||
project_id=project_id,
|
||
status=PrivatePortraitAssetStatus.ACTIVE.value,
|
||
keyword=keyword,
|
||
asset_type=asset_type,
|
||
page=page,
|
||
page_size=page_size,
|
||
)
|
||
return items, total
|
||
|
||
|
||
async def get_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
|
||
"""素材详情(权限校验)。"""
|
||
row = (await db.execute(
|
||
select(VpV3Asset).where(
|
||
VpV3Asset.remote_asset_id == asset_id,
|
||
VpV3Asset.api_key_id == api_key_id,
|
||
VpV3Asset.deleted_at.is_(None),
|
||
).limit(1)
|
||
)).scalar_one_or_none()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="虚拟素材不存在")
|
||
return row
|
||
|
||
|
||
async def sync_asset_status(
|
||
db: AsyncSession,
|
||
*,
|
||
api_key_id: str,
|
||
asset_id: str,
|
||
execution_guard: Callable[[], Awaitable[None]] | None = None,
|
||
) -> VpV3Asset:
|
||
"""主动同步素材状态(调 Ark GetAsset)。
|
||
|
||
注意:如果素材没有 remote_asset_id(远端还未 CreateAsset 返回),直接跳过并返回当前本地快照。
|
||
"""
|
||
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
|
||
if not asset.remote_asset_id:
|
||
return asset
|
||
try:
|
||
resp = await ArkPrivateAssetClient().get_asset(
|
||
project_name=asset.remote_project_name, asset_id=asset.remote_asset_id,
|
||
)
|
||
if execution_guard is not None:
|
||
await execution_guard()
|
||
_apply_get_asset_response(asset, resp)
|
||
except Exception as exc: # noqa: BLE001
|
||
# 异常分支也必须推进 poll 计数 + 重算下次轮询时间,避免无限调度且数据库无变化
|
||
asset.last_poll_at = _bj_now()
|
||
asset.poll_count = int(asset.poll_count or 0) + 1
|
||
asset.error_message = f"同步状态失败:{exc}"
|
||
logger.warning("vp_v3 同步素材状态失败:asset_id=%s err=%s", asset_id, exc)
|
||
# 异常情况仍然保持 CREATING,按指数退避重算 next_poll_at
|
||
delays = [3, 6, 12, 30, 60]
|
||
idx = min(asset.poll_count, len(delays) - 1)
|
||
asset.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
|
||
finally:
|
||
await db.flush()
|
||
await refresh_project_counters(db, [asset.project_id])
|
||
return asset
|
||
|
||
|
||
def _apply_get_asset_response(a: VpV3Asset, resp: dict) -> None:
|
||
"""把 Ark GetAsset 响应应用到本地记录(状态、URL、审核信息)。"""
|
||
# 先推进公共轮询字段(无论状态映射结果如何,只要调了一次 GetAsset 都必须记录)
|
||
a.last_poll_at = _bj_now()
|
||
a.poll_count = int(a.poll_count or 0) + 1
|
||
a.moderation_json = _json(resp)
|
||
a.raw_response_json = _json(resp)
|
||
|
||
# Status 映射:火山 Status 字段 → 本地枚举
|
||
status_raw = str(resp.get("Status") or resp.get("status") or "").lower()
|
||
if status_raw in {"active", "success", "done", "available"}:
|
||
a.status = PrivatePortraitAssetStatus.ACTIVE.value
|
||
elif status_raw in {"creating", "pending", "processing", "auditing"}:
|
||
a.status = PrivatePortraitAssetStatus.CREATING.value
|
||
elif status_raw in {"failed", "error", "rejected", "invalid"}:
|
||
a.status = PrivatePortraitAssetStatus.FAILED.value
|
||
msg = resp.get("Message") or resp.get("message") or resp.get("Error") or resp.get("error")
|
||
if msg:
|
||
a.error_message = str(msg)
|
||
else:
|
||
# 未知状态保持原
|
||
pass
|
||
|
||
# URL 续期
|
||
url = resp.get("URL") or resp.get("url") or resp.get("Url")
|
||
if url:
|
||
a.remote_url = url
|
||
a.preview_url = url
|
||
a.remote_url_expired_at = None # 无法解析过期时间就不填
|
||
# 视频时长
|
||
if not a.video_duration:
|
||
dur = resp.get("Duration") or resp.get("duration")
|
||
if dur is not None:
|
||
try:
|
||
a.video_duration = float(dur)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 大小
|
||
if not a.file_size_bytes:
|
||
size = resp.get("FileSize") or resp.get("fileSize") or resp.get("size")
|
||
if size is not None:
|
||
try:
|
||
a.file_size_bytes = int(size)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 状态判断下次轮询时间
|
||
if a.status == PrivatePortraitAssetStatus.CREATING.value:
|
||
# 指数退避:3s → 6s → 12s → 30s → 60s,最多 60s
|
||
delays = [3, 6, 12, 30, 60]
|
||
idx = min(a.poll_count, len(delays) - 1)
|
||
a.next_poll_at = _bj_now() + timedelta(seconds=delays[idx])
|
||
elif a.status == PrivatePortraitAssetStatus.FAILED.value:
|
||
a.next_poll_at = None # 失败不再轮询
|
||
elif a.status == PrivatePortraitAssetStatus.ACTIVE.value:
|
||
a.next_poll_at = None # 成功不再轮询
|
||
|
||
|
||
async def soft_delete_asset(db: AsyncSession, *, api_key_id: str, asset_id: str) -> VpV3Asset:
|
||
"""软删素材(本地先标记为删除中,同步删除本地落盘文件,重新计算项目计数和配额 used,然后 commit 后再投递异步远端删除任务)。"""
|
||
asset = await get_asset(db, api_key_id=api_key_id, asset_id=asset_id)
|
||
pid = asset.project_id
|
||
now = _bj_now()
|
||
asset.deleted_at = now
|
||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
|
||
asset.status = PrivatePortraitAssetStatus.DELETING.value
|
||
# 本地落盘文件:立刻删(成功失败都不影响状态,避免占磁盘;失败仅 log)
|
||
if asset.source_url:
|
||
_safe_delete_local_file(asset.source_url)
|
||
await db.flush()
|
||
await refresh_project_counters(db, [pid])
|
||
q = await get_quota(db, api_key_id=api_key_id, refresh=True)
|
||
return asset
|
||
|
||
|
||
# V3 专属的远端删除服务
|
||
V3_DOMAIN = "virtual_portrait_v3"
|
||
|
||
|
||
async def _load_v3_asset_delete_snapshot(db: AsyncSession, *, asset_id: str) -> dict | None:
|
||
"""加载 V3 素材删除快照。"""
|
||
asset = (
|
||
await db.execute(
|
||
select(VpV3Asset).where(VpV3Asset.remote_asset_id == asset_id).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not asset:
|
||
return None
|
||
return {
|
||
"owner_id": str(asset.id),
|
||
"owner_type": "asset",
|
||
"api_key_id": str(asset.api_key_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 ""),
|
||
"asset_type": str(asset.asset_type or ""),
|
||
"remote_delete_status": str(asset.remote_delete_status or ""),
|
||
}
|
||
|
||
|
||
async def _apply_v3_asset_delete_result(
|
||
db: AsyncSession,
|
||
*,
|
||
asset_id: str,
|
||
remote_id: str | None,
|
||
succeeded: bool,
|
||
skipped: bool = False,
|
||
error: BaseException | None = None,
|
||
) -> None:
|
||
"""应用 V3 素材远端删除结果到数据库。"""
|
||
asset = (
|
||
await db.execute(
|
||
select(VpV3Asset).where(VpV3Asset.id == asset_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 remote_id and str(asset.remote_asset_id or "") != remote_id:
|
||
raise RuntimeError("V3 素材远程 Asset 已变化,旧删除结果已丢弃")
|
||
now = _bj_now()
|
||
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_v3_asset_remote(
|
||
db: AsyncSession,
|
||
*,
|
||
asset_id: str,
|
||
execution_guard: Callable[[], Awaitable[None]] | None = None,
|
||
) -> None:
|
||
"""V3 素材远端删除(异步 Celery 任务调用)。"""
|
||
snapshot = await _load_v3_asset_delete_snapshot(db, asset_id=asset_id)
|
||
if snapshot is None:
|
||
log_operation_event(
|
||
domain=V3_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["remote_delete_status"] in {
|
||
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
|
||
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
|
||
}:
|
||
await db.rollback()
|
||
return
|
||
|
||
remote_id = snapshot["remote_id"]
|
||
if not remote_id:
|
||
await _apply_v3_asset_delete_result(
|
||
db,
|
||
asset_id=asset_id,
|
||
remote_id=None,
|
||
succeeded=False,
|
||
skipped=True,
|
||
)
|
||
log_operation_event(
|
||
domain=V3_DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SKIPPED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
project_id=snapshot["project_id"],
|
||
asset_id=asset_id,
|
||
message="远程删除跳过:素材没有 remote_asset_id",
|
||
)
|
||
return
|
||
|
||
log_operation_event(
|
||
domain=V3_DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value,
|
||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
project_id=snapshot["project_id"],
|
||
asset_id=asset_id,
|
||
detail={
|
||
"remote_asset_id": remote_id,
|
||
"remote_project_name": snapshot["remote_project_name"],
|
||
"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=remote_id,
|
||
)
|
||
succeeded = True
|
||
except Exception as exc:
|
||
remote_error = exc
|
||
# 404 视为幂等成功
|
||
if "not found" in str(exc).lower() or "404" in str(exc):
|
||
succeeded = True
|
||
|
||
if execution_guard is not None:
|
||
await execution_guard()
|
||
|
||
await _apply_v3_asset_delete_result(
|
||
db,
|
||
asset_id=asset_id,
|
||
remote_id=remote_id,
|
||
succeeded=succeeded,
|
||
error=remote_error,
|
||
)
|
||
|
||
if succeeded:
|
||
log_operation_event(
|
||
domain=V3_DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value,
|
||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
project_id=snapshot["project_id"],
|
||
asset_id=asset_id,
|
||
message="远程资源不存在,按幂等删除成功处理" if remote_error is not None else None,
|
||
detail={
|
||
"remote_asset_id": remote_id,
|
||
"remote_project_name": snapshot["remote_project_name"],
|
||
},
|
||
)
|
||
else:
|
||
assert remote_error is not None
|
||
log_operation_error(
|
||
domain=V3_DOMAIN,
|
||
event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_FAILED.value,
|
||
source=PrivatePortraitEventSource.CELERY.value,
|
||
project_id=snapshot["project_id"],
|
||
asset_id=asset_id,
|
||
exc=remote_error,
|
||
)
|