1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api

2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
This commit is contained in:
2026-08-06 13:13:28 +08:00
parent a55d4d649c
commit 0c511f3451
102 changed files with 13986 additions and 41 deletions
@@ -0,0 +1,13 @@
from app.services.virtual_portrait_v3 import (
quota_service,
project_service,
asset_service,
upload_service,
)
__all__ = [
"quota_service",
"project_service",
"asset_service",
"upload_service",
]
@@ -0,0 +1,674 @@
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+8naive 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/大小/网络)
- 失败:抛 HTTPException400/413/415/502/500),不留临时文件
4. 配额校验(素材数 + 存储 MB,用下载后的实际 file_size_bytes
- 失败:**立刻删除本地已下载的文件**,避免占用磁盘;再抛 403
5. Video 时长校验(payload.video_duration 优先,否则用 ffprobe 探测到的值;>60s 报错)
- 失败:删本地文件 → 抛 400
6. 写 VpV3AssetCreating 状态,带 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,
)
@@ -0,0 +1,184 @@
"""VP V3 虚拟素材库专用日志服务。
统一记录所有 VP V3 相关操作日志到 logs/virtual_portrait_v3/ 目录。
按天分文件,便于管理和排查问题。
"""
import json
import logging
import os
from datetime import datetime, timezone
from app.config import settings
# === 日志目录 ===
BASE_LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
"log", "virtual_portrait_v3",
)
os.makedirs(BASE_LOG_DIR, exist_ok=True)
class _DailyFileHandler(logging.Handler):
"""按天写入不同日志文件的处理器。"""
def __init__(self, log_dir: str):
super().__init__()
self.log_dir = log_dir
self._current_date = None
self._file_handler = None
self._open_file()
def _open_file(self):
"""打开当天的日志文件。"""
now = datetime.now(timezone.utc)
date_str = now.strftime("%Y-%m-%d")
if date_str == self._current_date and self._file_handler:
return
if self._file_handler:
self._file_handler.close()
self._current_date = date_str
filepath = os.path.join(self.log_dir, f"{date_str}.log")
self._file_handler = open(filepath, "a", encoding="utf-8")
def emit(self, record):
try:
self._open_file()
msg = self.format(record)
self._file_handler.write(msg + "\n")
self._file_handler.flush()
except Exception:
self.handleError(record)
def close(self):
if self._file_handler:
self._file_handler.close()
super().close()
def _create_logger(name: str, filename: str | None = None) -> logging.Logger:
"""创建专用 Logger。"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# 避免重复添加 handler
if logger.handlers:
return logger
# 按天写入文件
handler = _DailyFileHandler(BASE_LOG_DIR)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setFormatter(formatter)
logger.addHandler(handler)
# 不向上传播到 root logger(避免重复输出到控制台)
logger.propagate = False
return logger
# === 专用 Logger 实例 ===
asset_logger = _create_logger("vp_v3.asset")
project_logger = _create_logger("vp_v3.project")
quota_logger = _create_logger("vp_v3.quota")
api_logger = _create_logger("vp_v3.api")
def log_asset_event(
event_type: str,
api_key_id: str,
asset_id: str | None = None,
project_id: str | None = None,
status: str | None = None,
detail: dict | None = None,
error: str | None = None,
):
"""记录素材相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"asset_id": asset_id,
"project_id": project_id,
"status": status,
"detail": detail or {},
}
if error:
log_data["error"] = error
asset_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
asset_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_project_event(
event_type: str,
api_key_id: str,
project_id: str | None = None,
status: str | None = None,
detail: dict | None = None,
error: str | None = None,
):
"""记录项目相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"project_id": project_id,
"status": status,
"detail": detail or {},
}
if error:
log_data["error"] = error
project_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
project_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_quota_event(
event_type: str,
api_key_id: str,
quota_type: str,
amount: float,
quota_before: float | None = None,
quota_after: float | None = None,
detail: dict | None = None,
):
"""记录配额相关事件。"""
log_data = {
"event_type": event_type,
"api_key_id": api_key_id,
"quota_type": quota_type,
"amount": amount,
"quota_before": quota_before,
"quota_after": quota_after,
"detail": detail or {},
}
quota_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
def log_api_request(
method: str,
path: str,
api_key_id: str,
status_code: int,
duration_ms: int,
error: str | None = None,
):
"""记录 API 请求。"""
log_data = {
"method": method,
"path": path,
"api_key_id": api_key_id,
"status_code": status_code,
"duration_ms": duration_ms,
}
if error:
log_data["error"] = error
api_logger.error(json.dumps(log_data, ensure_ascii=False, default=str))
else:
api_logger.info(json.dumps(log_data, ensure_ascii=False, default=str))
@@ -0,0 +1,557 @@
from __future__ import annotations
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from fastapi import HTTPException
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetStatus,
PrivatePortraitAssetType,
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.virtual_portrait_v3 import VpV3Asset, VpV3Project
from app.schemas.virtual_portrait_v3.project import (
VpV3ProjectCreate,
VpV3ProjectListOut,
VpV3ProjectOut,
VpV3ProjectUpdate,
)
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.virtual_portrait_v3.quota_service import (
_bytes_to_mb,
_refresh_quota_used,
_slug,
check_project_quota,
get_quota,
remote_group_name,
remote_project_name,
)
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
DOMAIN = "virtual_portrait_v3"
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive 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 project_to_out(p: VpV3Project) -> VpV3ProjectOut:
return VpV3ProjectOut(
project_id=p.remote_group_id,
name=p.name,
description=p.description,
status=p.status,
asset_count=int(p.asset_count or 0),
active_asset_count=int(p.active_asset_count or 0),
image_asset_count=int(p.image_asset_count or 0),
video_asset_count=int(p.video_asset_count or 0),
storage_mb_used=float(p.storage_mb_used or 0),
remote_delete_status=p.remote_delete_status or PrivatePortraitRemoteDeleteStatus.NONE.value,
error_message=p.error_message,
created_at=p.created_at,
updated_at=p.updated_at,
)
# ---------------------------------------------------------------------------
# Project CRUD
# ---------------------------------------------------------------------------
async def create_project(
db: AsyncSession,
*,
api_key_id: str,
payload: VpV3ProjectCreate,
) -> VpV3Project:
"""创建虚拟素材项目(同步调用 Ark CreateAssetGroup)。
1. 配额校验
2. 本地落库 status=creating_remote_group
3. 调 Ark CreateAssetGroup 拿 remote_group_id
4. 本地更新为 active,返回
"""
await check_project_quota(db, api_key_id=api_key_id, delta=1)
# slug = _slug(payload.name)
proj = VpV3Project(
id=generate_id(),
api_key_id=api_key_id,
name=payload.name.strip()[:128],
name_slug=payload.name.strip()[:128],
description=payload.description,
remote_project_name=remote_project_name(),
remote_group_id="",
status=PrivatePortraitProjectStatus.CREATING_REMOTE_GROUP.value,
asset_count=0,
active_asset_count=0,
image_asset_count=0,
video_asset_count=0,
storage_mb_used=0,
)
db.add(proj)
await db.flush()
await db.refresh(proj)
group_name = remote_group_name(api_key_id=api_key_id, project_slug=proj.name_slug,id=proj.id)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
detail={"remote_group_name": group_name, "remote_project_name": proj.remote_project_name},
)
try:
resp = await ArkPrivateAssetClient().create_asset_group(
project_name=proj.remote_project_name,
name=group_name,
description=payload.description,
group_type=PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
)
remote_group_id = resp.get("Id") or resp.get("GroupId") or resp.get("groupId")
if not remote_group_id:
raise RuntimeError("CreateAssetGroup 未返回素材组 ID")
proj.remote_group_id = str(remote_group_id)
proj.remote_group_name = group_name
proj.status = PrivatePortraitProjectStatus.ACTIVE.value
proj.raw_response_json = _json(resp)
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
detail={"remote_group_id": remote_group_id, "group_name": group_name},
)
return proj
except Exception as exc: # noqa: BLE001
proj.status = PrivatePortraitProjectStatus.CREATE_GROUP_FAILED.value
proj.error_message = str(exc)
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.VIRTUAL_ASSET_GROUP_CREATE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
api_key_id=api_key_id,
project_id=proj.id,
exc=exc,
)
raise HTTPException(status_code=502, detail=f"创建虚拟素材项目失败:{exc}") from exc
async def list_projects(
db: AsyncSession,
*,
api_key_id: str,
page: int,
page_size: int,
keyword: str | None = None,
status: str | None = None,
) -> tuple[list[VpV3Project], int]:
"""按 API Key 分页查询项目列表。"""
conds = [VpV3Project.api_key_id == api_key_id, VpV3Project.deleted_at.is_(None)]
if keyword:
conds.append(VpV3Project.name.ilike(f"%{keyword}%"))
if status:
conds.append(VpV3Project.status == status)
count_result = await db.execute(
select(func.count(VpV3Project.id)).where(*conds)
)
total = int(count_result.scalar() or 0)
q = (
select(VpV3Project)
.where(*conds)
.order_by(VpV3Project.created_at.desc())
.limit(page_size)
.offset((page - 1) * page_size)
)
items = list((await db.execute(q)).scalars().all())
return items, total
async def get_project(db: AsyncSession, *, api_key_id: str, project_id: str) -> VpV3Project:
"""获取项目详情(权限校验)。"""
row = (await db.execute(
select(VpV3Project).where(
VpV3Project.remote_group_id == project_id,
VpV3Project.api_key_id == api_key_id,
VpV3Project.deleted_at.is_(None),
).limit(1)
)).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="虚拟素材项目不存在")
return row
async def update_project(
db: AsyncSession,
*,
api_key_id: str,
project_id: str,
payload: VpV3ProjectUpdate,
) -> VpV3Project:
"""更新项目展示信息(名称/描述,不会重新创建远端 Group)。"""
proj = await get_project(db, api_key_id=api_key_id, project_id=project_id)
changed = False
if payload.name is not None and payload.name != proj.name:
proj.name = payload.name.strip()[:128]
proj.name_slug = _slug(payload.name)
changed = True
if payload.description is not None and payload.description != proj.description:
proj.description = payload.description
changed = True
if changed:
await db.flush()
return proj
async def soft_delete_project(
db: AsyncSession,
*,
api_key_id: str,
project_id: str,
) -> VpV3Project:
"""软删项目和其下所有素材(本地先删,等 commit 后再投递异步远端删除任务)。
会把 quota used 重新刷新一次。
"""
proj = await get_project(db, api_key_id=api_key_id, project_id=project_id)
now = _bj_now()
proj.deleted_at = now
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
proj.status = PrivatePortraitProjectStatus.DELETING.value
# 级联软删其下所有素材
await db.execute(
VpV3Asset.__table__.update() # type: ignore[attr-defined]
.where(
VpV3Asset.project_id == proj.id,
VpV3Asset.deleted_at.is_(None),
)
.values(
deleted_at=now,
remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
)
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
return proj
# ---------------------------------------------------------------------------
# 项目计数刷新(增删素材后调用,用于项目列表快速显示)
# ---------------------------------------------------------------------------
async def refresh_project_counters(db: AsyncSession, project_ids: list[str]) -> None:
"""按真实数据刷新项目 asset 计数和 storage。"""
if not project_ids:
return
for pid in project_ids:
row = (await db.execute(
select(
func.count(VpV3Asset.id),
func.sum(case((VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value, 1), else_=0)),
func.sum(case((VpV3Asset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
func.sum(case((VpV3Asset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
func.sum(case(
(VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value,
case((VpV3Asset.asset_type == PrivatePortraitAssetType.IMAGE.value, 1), else_=0)),
else_=0
)),
func.sum(case(
(VpV3Asset.status == PrivatePortraitAssetStatus.ACTIVE.value,
case((VpV3Asset.asset_type == PrivatePortraitAssetType.VIDEO.value, 1), else_=0)),
else_=0
)),
func.coalesce(func.sum(VpV3Asset.file_size_bytes), 0),
).where(
VpV3Asset.project_id == pid,
VpV3Asset.deleted_at.is_(None),
)
)).one()
(total, active, img_cnt, vid_cnt, active_img, active_vid, storage_bytes) = row
proj = (await db.execute(
select(VpV3Project).where(VpV3Project.id == pid).limit(1)
)).scalar_one_or_none()
if proj:
proj.asset_count = int(total or 0)
proj.active_asset_count = int(active or 0)
proj.image_asset_count = int(img_cnt or 0)
proj.video_asset_count = int(vid_cnt or 0)
proj.active_image_asset_count = int(active_img or 0)
proj.active_video_asset_count = int(active_vid or 0)
proj.storage_mb_used = float(_bytes_to_mb(storage_bytes))
# V3 专属的项目远端删除服务
V3_DOMAIN = "virtual_portrait_v3"
async def _load_v3_project_delete_snapshot(db: AsyncSession, *, project_id: str) -> dict | None:
"""加载 V3 项目删除快照。"""
proj = (
await db.execute(
select(VpV3Project).where(VpV3Project.id == project_id).limit(1)
)
).scalar_one_or_none()
if not proj:
return None
return {
"owner_id": str(proj.id),
"owner_type": "project",
"api_key_id": str(proj.api_key_id),
"remote_id": str(proj.remote_group_id) if proj.remote_group_id else None,
"remote_project_name": str(proj.remote_project_name or ""),
"remote_delete_status": str(proj.remote_delete_status or ""),
}
async def _apply_v3_project_delete_result(
db: AsyncSession,
*,
project_id: str,
remote_id: str | None,
succeeded: bool,
skipped: bool = False,
error: BaseException | None = None,
) -> None:
"""应用 V3 项目远端删除结果到数据库。"""
proj = (
await db.execute(
select(VpV3Project)
.where(VpV3Project.id == project_id)
.with_for_update()
.limit(1)
)
).scalar_one_or_none()
if not proj:
return
if proj.remote_delete_status in {
PrivatePortraitRemoteDeleteStatus.SUCCESS.value,
PrivatePortraitRemoteDeleteStatus.SKIPPED.value,
}:
return
if remote_id and str(proj.remote_group_id or "") != remote_id:
raise RuntimeError("V3 项目远程 Group 已变化,旧删除结果已丢弃")
now = _bj_now()
if skipped:
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SKIPPED.value
proj.remote_delete_error = None
elif succeeded:
proj.status = PrivatePortraitProjectStatus.DELETED.value
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
proj.remote_deleted_at = now
proj.remote_delete_error = None
else:
proj.status = PrivatePortraitProjectStatus.DELETED.value
proj.remote_delete_status = PrivatePortraitRemoteDeleteStatus.FAILED.value
proj.remote_delete_error = str(error or "远程删除失败")
await db.flush()
# 刷新配额
quota = await get_quota(db, api_key_id=proj.api_key_id, refresh=False)
await _refresh_quota_used(db, quota)
async def delete_v3_project_remote(
db: AsyncSession,
*,
project_id: str,
execution_guard: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""V3 项目远端删除(异步 Celery 任务调用)。
会先级联删除项目下所有素材的远端资源,再删除项目的远端 Group。
"""
# 先删除项目下所有素材的远端资源
assets = (
await db.execute(
select(VpV3Asset).where(
VpV3Asset.project_id == project_id,
VpV3Asset.deleted_at.is_not(None),
VpV3Asset.remote_delete_status == PrivatePortraitRemoteDeleteStatus.PENDING.value,
)
)
).scalars().all()
for asset in assets:
if asset.remote_asset_id:
try:
await ArkPrivateAssetClient(for_celery=True).delete_asset(
project_name=asset.remote_project_name,
asset_id=asset.remote_asset_id,
)
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=True,
)
except Exception as exc:
if "not found" in str(exc).lower() or "404" in str(exc):
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=True,
)
else:
await _apply_v3_asset_delete_result_for_project(
db,
asset_id=asset.id,
succeeded=False,
error=exc,
)
# 再删除项目的远端 Group
snapshot = await _load_v3_project_delete_snapshot(db, project_id=project_id)
if snapshot is None:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_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_project_delete_result(
db,
project_id=project_id,
remote_id=None,
succeeded=False,
skipped=True,
)
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SKIPPED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程删除跳过:项目没有 remote_group_id",
)
return
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_START.value,
event_status=PrivatePortraitEventStatus.PENDING.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
detail={
"remote_group_id": remote_id,
"remote_project_name": snapshot["remote_project_name"],
},
)
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=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_project_delete_result(
db,
project_id=project_id,
remote_id=remote_id,
succeeded=succeeded,
error=remote_error,
)
if succeeded:
log_operation_event(
domain=V3_DOMAIN,
event_type=PrivatePortraitEventType.PROJECT_DELETE_REMOTE_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
message="远程 Group 不存在,按幂等删除成功处理" if remote_error is not None else None,
detail={
"remote_group_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.PROJECT_DELETE_REMOTE_FAILED.value,
source=PrivatePortraitEventSource.CELERY.value,
project_id=project_id,
exc=remote_error,
)
async def _apply_v3_asset_delete_result_for_project(
db: AsyncSession,
*,
asset_id: str,
succeeded: bool,
error: BaseException | None = None,
) -> None:
"""项目删除时级联应用素材删除结果。"""
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 succeeded:
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.SUCCESS.value
asset.remote_deleted_at = _bj_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()
@@ -0,0 +1,163 @@
from __future__ import annotations
import re
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.private_portrait import (
PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME,
PRIVATE_PORTRAIT_VIRTUAL_GROUP_TYPE,
PrivatePortraitAssetType,
PrivatePortraitAssetStatus,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
)
from app.models.virtual_portrait_v3 import (
VpV3ApiKeyQuota,
VpV3Asset,
VpV3Project,
)
from app.utils.id_gen import generate_id
MB_BYTES = 1024 * 1024
_SAFE_SLUG = re.compile(r"[^a-zA-Z0-9_-]")
def _slug(name: str) -> str:
if not name:
return "unnamed"
return _SAFE_SLUG.sub("_", name.strip())[:80] or "unnamed"
def _bytes_to_mb(b: int | float | None) -> float:
if not b:
return 0.0
return round(b / MB_BYTES, 3)
# ---------------------------------------------------------------------------
# 配额读写(确保 VpV3ApiKeyQuota 记录存在)
# ---------------------------------------------------------------------------
async def _upsert_quota(db: AsyncSession, api_key_id: str) -> VpV3ApiKeyQuota:
"""获取配额记录;不存在则创建(默认全 0=不可用)。"""
from sqlalchemy.dialects.postgresql import insert
stmt = (
insert(VpV3ApiKeyQuota)
.values(
id=generate_id(),
api_key_id=api_key_id,
project_limit=0,
asset_limit=0,
storage_mb_limit=0,
project_used=0,
asset_used=0,
storage_mb_used=0,
)
.on_conflict_do_nothing(index_elements=["api_key_id"])
)
await db.execute(stmt)
row = (await db.execute(
select(VpV3ApiKeyQuota).where(VpV3ApiKeyQuota.api_key_id == api_key_id).limit(1)
)).scalar_one()
return row
async def _refresh_quota_used(db: AsyncSession, quota: VpV3ApiKeyQuota) -> None:
"""按真实数据重算已使用量(最终一致性)。"""
project_result = await db.execute(
select(func.count(VpV3Project.id)).where(
VpV3Project.api_key_id == quota.api_key_id,
VpV3Project.deleted_at.is_(None),
)
)
asset_result = await db.execute(
select(
func.count(VpV3Asset.id),
func.coalesce(func.sum(VpV3Asset.file_size_bytes), 0),
).where(
VpV3Asset.api_key_id == quota.api_key_id,
VpV3Asset.deleted_at.is_(None),
)
)
project_used = project_result.scalar() or 0
asset_row = asset_result.one()
asset_used = asset_row[0] or 0
storage_bytes = asset_row[1] or 0
quota.project_used = int(project_used)
quota.asset_used = int(asset_used)
quota.storage_mb_used = int(_bytes_to_mb(storage_bytes))
async def get_quota(db: AsyncSession, *, api_key_id: str, refresh: bool = True) -> VpV3ApiKeyQuota:
"""获取当前 API Key 的配额(含已使用量)。不存在则创建默认 0。"""
quota = await _upsert_quota(db, api_key_id)
if refresh:
await _refresh_quota_used(db, quota)
return quota
async def ensure_quota_enabled(db: AsyncSession, *, api_key_id: str) -> VpV3ApiKeyQuota:
"""校验是否已启用虚拟素材库功能,未启用直接 403。返回已刷新的配额。
判定口径(与后台设置保持一致):只要「项目数上限」或「素材数上限」任一 > 0 即视为启用;
存储上限已从配置中移除(不再作为启用条件,也不做硬性限制,仅保留数据库字段做统计展示)。
"""
quota = await get_quota(db, api_key_id=api_key_id, refresh=True)
if (quota.project_limit or 0) <= 0 and (quota.asset_limit or 0) <= 0:
raise HTTPException(status_code=403, detail="当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额")
return quota
def _check(limit: int | None, used: int | float | None, delta: int | float, field: str) -> None:
"""通用配额上限校验。
约定:limit <= 0 视为该维度「未配置 / 不做限制」,此时直接跳过不报错;
只有 limit > 0 时才按「已用 + 本次 <= 上限」判断,避免影响已移除的维度(如存储上限)。
"""
if (limit or 0) <= 0:
return # 不限制,直接通过
if (used or 0) + delta > limit:
raise HTTPException(
status_code=403,
detail=f"虚拟素材库配额不足:{field} 上限 {limit},已使用 {used},本次需要 {delta},超出上限",
)
async def check_project_quota(db: AsyncSession, *, api_key_id: str, delta: int = 1) -> VpV3ApiKeyQuota:
"""创建项目前校验配额。"""
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
_check(quota.project_limit, quota.project_used, delta, "项目数")
return quota
async def check_asset_quota(
db: AsyncSession,
*,
api_key_id: str,
asset_count_delta: int = 1,
file_size_bytes: int | None = None,
) -> VpV3ApiKeyQuota:
"""上传素材前校验配额。
注:「存储空间上限」已从业务约束中移除(不再做硬性配额限制),仅保留素材数量上限
与项目数量上限的校验;storage_mb_used 字段仍会在 get_quota 中刷新用于统计展示。
"""
del file_size_bytes # 不再用于配额校验(仅保留形参兼容现有调用点)
quota = await ensure_quota_enabled(db, api_key_id=api_key_id)
_check(quota.asset_limit, quota.asset_used, asset_count_delta, "素材总数")
return quota
def remote_project_name() -> str:
"""火山 ProjectNameV3 中转统一共用这个 Project)。"""
return PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
def remote_group_name(*, api_key_id: str, project_slug: str, id: str) -> str:
"""火山 GroupNamevp-api-{api_key_id_short}-{id}-{slug} 最多 128 字符。"""
short_key = (api_key_id or "")
return f"vp-api-{short_key}-{id}-{project_slug}"[:128]
@@ -0,0 +1,547 @@
from __future__ import annotations
import hashlib
import logging
import mimetypes
import os
import shutil
import subprocess
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse
import httpx
from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.virtual_portrait_v3.upload import VpV3UploadOut
from app.services.video_cover_service import get_ffmpeg_bin
logger = logging.getLogger("videogen")
# 北京时间(UTC+8)统一基准:V3 业务所有时间写入 / 时间比较唯一参考
_BJ_TZ = timezone(timedelta(hours=8))
def _bj_now() -> datetime:
"""返回当前北京时间(UTC+8naive datetime(去掉 tzinfo,与 DB naive 存储一致)。"""
return datetime.now(_BJ_TZ).replace(tzinfo=None)
VP_V3_IMAGE_MAX_BYTES = 10 * 1024 * 1024
VP_V3_VIDEO_MAX_BYTES = 100 * 1024 * 1024
VP_V3_MODULE_NAME = "vp_v3_virtual"
IMAGE_EXT_ALLOWED = {"jpg", "jpeg", "png", "webp", "bmp"}
VIDEO_EXT_ALLOWED = {"mp4", "mov", "m4v", "webm"}
IMAGE_MIME_ALLOWED = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/bmp",
}
VIDEO_MIME_ALLOWED = {
"video/mp4", "video/quicktime", "video/x-m4v", "video/webm",
}
# URL 下载相关默认值
URL_DOWNLOAD_CONNECT_TIMEOUT_SEC = 15
URL_DOWNLOAD_READ_TIMEOUT_SEC = 300 # 大文件下载可以久一点,读的时候会按大小上限中断
URL_DOWNLOAD_MAX_REDIRECTS = 5
URL_DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1MB
class DownloadedAsset(NamedTuple):
"""URL 下载到本地后的结果。"""
url: str # 对外访问 URL(最终要存到 VpV3Asset.source_url 的)
filename: str # 落盘后的文件名
file_size_bytes: int # 实际文件大小
mime_type: str | None # 从响应头/扩展名推断出的 MIME
duration_seconds: float | None # 视频:ffprobe 探测到的时长(Image 为 None)
suggested_name: str | None # 从 URL 或 Content-Disposition 推断的展示名(无扩展名)
def _max_bytes(asset_type: str) -> int:
return VP_V3_VIDEO_MAX_BYTES if asset_type == UploadResourceTypeEnum.VIDEO.value else VP_V3_IMAGE_MAX_BYTES
def _allowed_mime_set(asset_type: str) -> set[str]:
return VIDEO_MIME_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_MIME_ALLOWED
def _safe_ext(filename: str, asset_type: str) -> str:
ext = (os.path.splitext(filename or "")[1].lower().lstrip(".") or "").strip()
allowed = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
if ext and ext in allowed:
return ext
# fallback
return "mp4" if asset_type == UploadResourceTypeEnum.VIDEO.value else "png"
def _get_ffprobe_bin() -> str:
ffmpeg = Path(get_ffmpeg_bin())
sibling = ffmpeg.with_name("ffprobe.exe" if ffmpeg.suffix.lower() == ".exe" else "ffprobe")
if sibling.exists():
return str(sibling)
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
if found:
return found
raise RuntimeError("未找到 ffprobe,请确保其与 FFMPEG_BIN 同目录或已加入 PATH")
def _probe_duration_optional(video_path: Path) -> float | None:
"""尝试 ffprobe 探测视频时长,失败不抛,返回 None 让调用方自己处理。"""
import json as _json
try:
ffprobe_bin = _get_ffprobe_bin()
# 检查 ffprobe 是否可用
if not shutil.which(ffprobe_bin) and ffprobe_bin == "ffprobe":
logger.warning("ffprobe 未在系统 PATH 中找到,无法探测视频时长。请安装 ffprobe 并添加到 PATH。")
return None
timeout = int(getattr(settings, "SHOT_FFPROBE_TIMEOUT_SECONDS", 20) or 20)
cmd = [
ffprobe_bin,
"-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(video_path),
]
completed = subprocess.run(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout, check=False,
)
if completed.returncode != 0:
logger.warning(
"ffprobe 执行失败: returncode=%s stderr=%s",
completed.returncode, completed.stderr[:200],
)
return None
if not completed.stdout:
return None
data = _json.loads(completed.stdout or "{}")
dur_raw = (data.get("format") or {}).get("duration")
if dur_raw is None:
return None
dur = float(dur_raw)
if dur <= 0:
return None
return dur
except subprocess.TimeoutExpired:
logger.warning("ffprobe 超时: %s", str(video_path))
return None
except Exception as exc: # noqa: BLE001
logger.warning("ffprobe 探测视频时长失败: %s", exc)
return None
def _build_destination(*, api_key_id: str, asset_type: str, original_filename: str, duration_seconds: float | None) -> tuple[Path, str, str]:
"""构建 vp_v3 上传存储路径 + 对外访问 URL。
存储路径:UPLOAD_LOCAL_PATH/api/private_portrait_virtual/{asset_type}/{yyyy}/{mm}/{dd}/{uuid}.{ext}
"""
now = _bj_now()
ext = _safe_ext(original_filename, asset_type)
safe_uuid = uuid.uuid4().hex
year = f"{now.year:04d}"
month = f"{now.month:02d}"
day = f"{now.day:02d}"
sub_type = "videos" if asset_type == UploadResourceTypeEnum.VIDEO.value else "images"
rel_dir = Path("api") / "private_portrait_virtual" / sub_type / year / month / day
filename = f"vp_v3_{safe_uuid}.{ext}"
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
final_path = base_dir / rel_dir / filename
# URL 前缀 /uploads/...
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = f"/{rel_dir.as_posix()}/{filename}".replace("//", "/")
url = base_url + rel_url
return final_path, url, filename
def _guess_filename_from_url(url: str, cd_header: str | None) -> str:
"""优先从 Content-Disposition 拿文件名,其次从 URL path 拿,再 fallback 到 uuid 名。"""
# 1. Content-Disposition
if cd_header:
# filename="a.jpg" 或 filename*=UTF-8''a.jpg
import re as _re
m1 = _re.search(r"""filename\*\s*=\s*UTF-8''([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m1:
from urllib.parse import unquote
try:
return unquote(m1.group(1).strip().strip('"').strip("'"))
except Exception: # noqa: BLE001
pass
m2 = _re.search(r"""filename\s*=\s*"([^"]+)""", cd_header, flags=_re.IGNORECASE)
if m2:
return m2.group(1)
m3 = _re.search(r"""filename\s*=\s*([^;]+)""", cd_header, flags=_re.IGNORECASE)
if m3:
return m3.group(1).strip().strip('"').strip("'")
# 2. URL path
try:
parsed = urlparse(url)
base = os.path.basename(parsed.path or "")
if base and "." in base:
return base
except Exception: # noqa: BLE001
pass
return f"vp_v3_{uuid.uuid4().hex[:12]}"
def _guess_ext_from_mime(mime: str | None, asset_type: str) -> str | None:
if not mime:
return None
# 按 asset_type 优先匹配
allowed_exts = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
guesses = mimetypes.guess_all_extensions(mime.strip().lower()) or []
for g in guesses:
ext = g.lower().lstrip(".")
if ext in allowed_exts:
return ext
# 额外的手写映射
extra_map = {
"image/jpeg": "jpg", "image/jpg": "jpg",
"video/quicktime": "mov", "video/x-m4v": "m4v",
}
if mime.lower() in extra_map and extra_map[mime.lower()] in allowed_exts:
return extra_map[mime.lower()]
return None
# ---------------------------------------------------------------------------
# 1) 上传本地文件(保留旧 API 但走下载流程的也可以共用保存逻辑)
# ---------------------------------------------------------------------------
async def upload_asset_file(
db: AsyncSession,
*,
api_key_id: str,
file: UploadFile,
asset_type: str,
duration_seconds: float | None = None,
) -> VpV3UploadOut:
"""V3 虚拟素材上传(独立实现,不经过用户容量账本 UploadResource)。
- 校验 MIME/扩展名/大小
- 落盘到 /uploads/images|videos/vp_v3/{api_key_id_short}/{yyyy}/{mm}/{dd}/
- 返回 url + 虚拟 resource_idhash 形式)
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材上传仅支持图片或视频")
max_size = _max_bytes(asset_type)
temp_path: str | None = None
try:
# 1. 落临时文件并限制大小
size_acc = 0
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_{uuid.uuid4().hex}")
with open(temp_path, "wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size_acc += len(chunk)
if size_acc > max_size:
raise HTTPException(
status_code=400,
detail=f"文件大小超出限制:{asset_type} 最大 {max_size // (1024*1024)} MB",
)
f.write(chunk)
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="空文件不允许上传")
# 2. 构建最终路径 + URL
final_path, url, safe_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=file.filename or safe_filename,
duration_seconds=duration_seconds,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 3. 虚拟 resource_id(用于素材删除时的文件清理定位:hash(url))
resource_id = "vpv3_" + hashlib.sha256(url.encode()).hexdigest()[:24]
return VpV3UploadOut(
url=url,
filename=safe_filename,
type=asset_type,
resource_id=resource_id,
file_size_bytes=file_size_bytes,
duration_seconds=duration_seconds,
)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 上传失败:%s", exc)
raise HTTPException(status_code=500, detail=f"上传失败:{exc}") from exc
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
# ---------------------------------------------------------------------------
# 2) URL 下载到本地(新流程:创建素材时一步到位,由 create_asset 内部调用)
# ---------------------------------------------------------------------------
async def download_url_to_local(
*,
api_key_id: str,
asset_type: str,
source_url: str,
requested_filename: str | None = None,
) -> DownloadedAsset:
"""把传入的远程 URLhttp/https)下载到本地 vp_v3 上传目录,返回本地 URL + 元信息。
完整的错误处理:
- 非法 URL → 400
- 连接/超时 → 502(外部资源不可达)
- HTTP 4xx/5xx → 502 带状态码
- Content-Type 不在允许列表 → 415
- 超出大小上限(读内容时逐 chunk 检查)→ 413
- 下载一半失败 → 清理临时文件,不留下半截
- 视频可选探测 ffprobe,失败不抛错(调用方自行用 payload.video_duration
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材仅支持图片或视频")
# URL 合法性
if not source_url or not isinstance(source_url, str):
raise HTTPException(status_code=400, detail="source_url 不能为空")
parsed = urlparse(source_url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise HTTPException(status_code=400, detail="source_url 必须是合法的 http(s) URL")
# 拒绝私有/内网地址(SSRF 防御的最小集;生产环境可再严格)
import ipaddress
host_only = parsed.hostname or ""
try:
ip_obj = ipaddress.ip_address(host_only)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local:
raise HTTPException(status_code=400, detail="source_url 不允许指向内网/本机地址")
except ValueError:
# 不是 IP,是域名 → 放行
pass
max_bytes = _max_bytes(asset_type)
allowed_mimes = _allowed_mime_set(asset_type)
# 用户代理:标成我们服务的 UA,避免一些图片防盗链 403
user_agent = (
"Mozilla/5.0 (compatible; VideoGenVPV3/1.0; +https://minzhongzc.com/)"
if getattr(settings, "VP_V3_DOWNLOAD_UA", None) is None
else str(getattr(settings, "VP_V3_DOWNLOAD_UA"))
)
temp_path: str | None = None
final_path: Path | None = None
# 外层初始化,保证 client.stream 内部 raise 的情况下外层仍然可访问
inferred_filename: str = f"vp_v3_{uuid.uuid4().hex[:12]}"
mime: str | None = None
size_acc: int = 0
try:
# --- 第一步:下载到临时文件,限制大小 + 校验响应头 ---
temp_dir = settings.UPLOAD_TEMP_DIR if settings and getattr(settings, "UPLOAD_TEMP_DIR", None) else "./uploads/_tmp_vp_v3"
Path(temp_dir).mkdir(parents=True, exist_ok=True)
temp_path = os.path.join(temp_dir, f"vp_v3_url_{uuid.uuid4().hex}")
transport = httpx.AsyncHTTPTransport(retries=1)
timeout = httpx.Timeout(
connect=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
read=URL_DOWNLOAD_READ_TIMEOUT_SEC,
write=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
pool=URL_DOWNLOAD_CONNECT_TIMEOUT_SEC,
)
headers = {"User-Agent": user_agent, "Accept": "*/*"}
async with httpx.AsyncClient(
timeout=timeout,
transport=transport,
follow_redirects=True,
max_redirects=URL_DOWNLOAD_MAX_REDIRECTS,
verify=bool(getattr(settings, "VP_V3_DOWNLOAD_VERIFY_SSL", True)),
) as client:
async with client.stream("GET", source_url.strip(), headers=headers) as resp:
# HTTP 状态码
if resp.status_code >= 400:
detail = f"远程资源返回状态码 {resp.status_code}"
try:
snippet = (await resp.aread())[:200]
if snippet:
detail += f",响应片段:{snippet.decode('utf-8', errors='ignore')}"
except Exception: # noqa: BLE001
pass
raise HTTPException(
status_code=502,
detail=f"source_url 下载失败(HTTP {resp.status_code}):" + detail,
)
# Content-Type 校验(没有就 fallback 到扩展名推断)
content_type_raw = resp.headers.get("Content-Type") or ""
mime = (content_type_raw.split(";")[0] or "").strip().lower() or None
if mime and mime not in allowed_mimes:
# 一些 CDN 会用 application/octet-stream,这种情况跳过 MIME 检查,用扩展名兜底
if mime != "application/octet-stream":
raise HTTPException(
status_code=415,
detail=(
f"不支持的 Content-Type{mime}"
f"{asset_type} 仅支持:{', '.join(sorted(allowed_mimes))}"
),
)
# Content-Length 预估检查(存在且超出就直接拒,不下载)
content_length = resp.headers.get("Content-Length")
if content_length:
try:
cl = int(content_length)
if cl > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源太大(Content-Length={cl}),超过 "
f"{asset_type} 上限 {max_bytes} 字节"
),
)
except ValueError:
pass
# filename 推断(用于扩展名 + 展示名)
cd = resp.headers.get("Content-Disposition")
inferred_filename = _guess_filename_from_url(source_url, cd)
if requested_filename:
# 若用户传了 name 就优先用它做展示名,但扩展名仍然以 mime/url 推断为准
try:
base_display = os.path.splitext(os.path.basename(requested_filename))[0]
old_ext = os.path.splitext(inferred_filename)[1] if inferred_filename else ""
inferred_filename = base_display + (old_ext or "")
except Exception: # noqa: BLE001
pass
# 扩展名再精化:如果 MIME 能得出扩展名,优先用
ext_from_mime = _guess_ext_from_mime(mime, asset_type)
if ext_from_mime:
try:
stem = os.path.splitext(inferred_filename)[0]
inferred_filename = f"{stem}.{ext_from_mime}"
except Exception: # noqa: BLE001
pass
# 流式下载到 temp_path,逐 chunk 检查大小
size_acc = 0
with open(temp_path, "wb") as f:
async for chunk in resp.aiter_bytes():
if not chunk:
continue
size_acc += len(chunk)
if size_acc > max_bytes:
raise HTTPException(
status_code=413,
detail=(
f"远程资源大小超过 {asset_type} 上限 "
f"{max_bytes // (1024*1024)} MB"
),
)
f.write(chunk)
# ======== 以下在 client.stream 退出后、但仍在 httpx.AsyncClient 上下文内执行 ========
# 空文件检查
file_size_bytes = size_acc
if file_size_bytes <= 0:
raise HTTPException(status_code=400, detail="远程 URL 返回空文件")
# --- 第二步:视频可选 ffprobe 探测时长 ---
duration: float | None = None
if asset_type == UploadResourceTypeEnum.VIDEO.value:
duration = _probe_duration_optional(Path(temp_path))
# --- 第三步:落到最终目录(与 _build_destination 一致的目录结构/权限) ---
final_path, url_out, final_filename = _build_destination(
api_key_id=api_key_id,
asset_type=asset_type,
original_filename=inferred_filename,
duration_seconds=duration,
)
final_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(temp_path, final_path)
temp_path = None
# 最终 MIME:按扩展名反推一个(如果之前没拿到)
if not mime:
mime, _ = mimetypes.guess_type(final_filename)
if not mime:
mime = "image/png" if asset_type == UploadResourceTypeEnum.IMAGE.value else "video/mp4"
suggested_name: str | None = None
try:
stem = os.path.splitext(inferred_filename or final_filename)[0]
if stem and not stem.startswith("vp_v3_"):
suggested_name = stem[:100] or None
except Exception: # noqa: BLE001
pass
return DownloadedAsset(
url=url_out,
filename=final_filename,
file_size_bytes=file_size_bytes,
mime_type=mime,
duration_seconds=duration,
suggested_name=suggested_name,
)
except HTTPException:
raise
except (httpx.TimeoutException, httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 连接/读取超时:{exc}") from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"远程 URL 下载失败:{exc}") from exc
except Exception as exc: # noqa: BLE001
logger.exception("vp_v3 URL 下载异常:url=%s err=%s", source_url, exc)
raise HTTPException(status_code=500, detail=f"URL 下载保存失败:{exc}") from exc
finally:
# 任何失败都清掉半截临时文件;但最终文件已经 move 过去了的就不动
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception: # noqa: BLE001
pass
def delete_local_file_by_url(local_url: str) -> bool:
"""素材删除时根据 source_url 删除本地落盘文件(非强制,失败不抛)。"""
if not local_url:
return False
try:
base_url = settings.UPLOAD_BASE_URL.rstrip("/") if settings and getattr(settings, "UPLOAD_BASE_URL", None) else "/uploads"
rel_url = local_url
if rel_url.startswith(base_url):
rel_url = rel_url[len(base_url):]
if not rel_url.startswith("/"):
return False
# /uploads/api/private_portrait_virtual/... → /api/private_portrait_virtual/... → UPLOAD_LOCAL_PATH/api/private_portrait_virtual/...
sub_part = rel_url[len("/uploads"):] if rel_url.startswith("/uploads") else rel_url
base_dir = Path(settings.UPLOAD_LOCAL_PATH) if settings.UPLOAD_LOCAL_PATH else Path("./storage/uploads")
target = (base_dir / sub_part.lstrip("/")).resolve()
base_dir_resolved = base_dir.resolve()
# 仅允许删除 base_dir 下的文件(目录穿越防御)
if not str(target).startswith(str(base_dir_resolved)):
return False
if target.is_file():
target.unlink(missing_ok=True)
return True
except Exception: # noqa: BLE001
logger.exception("vp_v3 清理本地文件失败:%s", local_url)
return False