1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理 3、增加apikey单独的模型定价 4、增加apikey调用情况 5、完善所有数据的注释增加
This commit is contained in:
@@ -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+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,
|
||||
)
|
||||
@@ -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+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 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:
|
||||
"""火山 ProjectName(V3 中转统一共用这个 Project)。"""
|
||||
return PRIVATE_PORTRAIT_REMOTE_PROJECT_NAME
|
||||
|
||||
|
||||
def remote_group_name(*, api_key_id: str, project_slug: str, id: str) -> str:
|
||||
"""火山 GroupName:vp-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+8)naive 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_id(hash 形式)
|
||||
"""
|
||||
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:
|
||||
"""把传入的远程 URL(http/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
|
||||
Reference in New Issue
Block a user