548 lines
23 KiB
Python
548 lines
23 KiB
Python
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.F_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.F_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.F_VIDEO.value else IMAGE_EXT_ALLOWED
|
||
if ext and ext in allowed:
|
||
return ext
|
||
# fallback
|
||
return "mp4" if asset_type == UploadResourceTypeEnum.F_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.F_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.F_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.F_IMAGE.value, UploadResourceTypeEnum.F_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.F_IMAGE.value, UploadResourceTypeEnum.F_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.F_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.F_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
|