feat: 优化图片资源删除管理,生图任务列表管理

This commit is contained in:
2026-08-30 14:50:33 +08:00
parent 4ee667142b
commit 957289f717
46 changed files with 4180 additions and 524 deletions
+142 -23
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import mimetypes
import shutil
import uuid
from pathlib import Path
@@ -13,11 +14,16 @@ from config import get_settings
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
def _ext_from_url(url: str) -> str:
ext = mimetypes.guess_extension(url.split("?")[0].lower()) or ".jpg"
if ext == ".jpe":
ext = ".jpg"
return ext
class ResourceSizeLimitError(ValueError):
"""上传、下载或本地读取超过单文件上限。"""
def __init__(self, limit_bytes: int, actual_bytes: int | None = None) -> None:
self.limit_bytes = limit_bytes
self.actual_bytes = actual_bytes
detail = f"图片文件超过大小上限(最多 {limit_bytes // 1024 // 1024} MB)"
if actual_bytes is not None:
detail += f",当前约 {actual_bytes / 1024 / 1024:.1f} MB"
super().__init__(detail)
def _ext_from_content_type(content_type: str) -> str:
@@ -34,40 +40,109 @@ def _ext_from_content_type(content_type: str) -> str:
return mapping.get(ctype, ".jpg")
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
"""下载远程字节。返回 (bytes, content_type)。"""
def image_extension(data: bytes, content_type: str = "") -> str:
"""按真实图片字节优先推断扩展名,避免 provider 错报 Content-Type。"""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
if data[:3] == b"GIF":
return ".gif"
return _ext_from_content_type(content_type)
async def download_bytes(
url: str,
referer: str | None = None,
timeout: float = 60.0,
max_bytes: int | None = None,
) -> tuple[bytes, str]:
"""流式下载远程图片并限制体积。返回 (bytes, content_type)。"""
limit = max_bytes or get_settings().image_download_max_bytes
if limit <= 0:
raise ValueError("图片下载大小上限必须大于 0")
headers = {"Referer": referer} if referer else {}
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return resp.content, ctype
async with client.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
length = resp.headers.get("content-length")
if length:
try:
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > limit:
raise ResourceSizeLimitError(limit, total)
chunks.append(chunk)
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return b"".join(chunks), ctype
def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes:
"""受单文件上限保护地读取本地媒体文件。"""
limit = max_bytes or get_settings().image_download_max_bytes
size = path.stat().st_size
if size > limit:
raise ResourceSizeLimitError(limit, size)
return path.read_bytes()
class LocalStorage:
"""开发期:落 data/media/,由 FastAPI /media 静态托管,返回 app_base_url 可访问 URL。"""
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
data, _ = await download_bytes(url, referer)
key = self._write(data, key_prefix, url)
return self.public_url(key)
data, ctype = await download_bytes(url, referer)
key = f"{key_prefix.rstrip('/') + '/' if key_prefix else ''}{uuid.uuid4().hex}{image_extension(data, ctype)}"
return await self.save_bytes(data, key, ctype)
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
key = self._write(data, "", key)
return self.public_url(key)
return self.public_url(self._write(data, key, content_type))
def _write(self, data: bytes, key_prefix: str, hint: str) -> str:
def _write(self, data: bytes, key: str, content_type: str) -> str:
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
ext = _ext_from_url(hint) if hint and not hint.startswith("data:") else ".jpg"
key = f"{key_prefix + '/' if key_prefix else ''}{uuid.uuid4().hex}{ext}"
(_LOCAL_ROOT / key).parent.mkdir(parents=True, exist_ok=True)
(_LOCAL_ROOT / key).write_bytes(data)
return key
safe_key = key.strip().lstrip("/")
if not safe_key:
safe_key = uuid.uuid4().hex
if not Path(safe_key).suffix:
safe_key += image_extension(data, content_type)
path = (_LOCAL_ROOT / safe_key).resolve()
if _LOCAL_ROOT.resolve() not in path.parents:
raise ValueError("非法存储 key")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return safe_key
def public_url(self, key: str) -> str:
settings = get_settings()
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
async def delete(self, key: str) -> None:
path = (_LOCAL_ROOT / key.strip().lstrip("/")).resolve()
if _LOCAL_ROOT.resolve() not in path.parents:
raise ValueError("非法存储 key")
if path.is_file():
path.unlink()
async def delete_prefix(self, prefix: str) -> int:
root = (_LOCAL_ROOT / prefix.strip().strip("/")).resolve()
if _LOCAL_ROOT.resolve() not in root.parents and root != _LOCAL_ROOT.resolve():
raise ValueError("非法存储前缀")
if not root.exists():
return 0
if root.is_file():
root.unlink()
return 1
count = sum(1 for path in root.rglob("*") if path.is_file())
shutil.rmtree(root)
return count
class QiniuStorage:
"""生产:上传七牛,返回绑定域名公网 URL(Ozon 可拉取)。"""
@@ -97,6 +172,33 @@ class QiniuStorage:
settings = get_settings()
return f"{settings.qiniu_domain.rstrip('/')}/{key}"
async def delete(self, key: str) -> None:
import qiniu
auth, settings = self._client()
_, info = qiniu.BucketManager(auth).delete(settings.qiniu_bucket, key)
if info.status_code not in (200, 612): # 612:对象已不存在,视为删除完成
raise RuntimeError(f"七牛删除失败:{info.error or info.text_body or info.status_code}")
async def delete_prefix(self, prefix: str) -> int:
import qiniu
auth, settings = self._client()
manager = qiniu.BucketManager(auth)
marker: str | None = None
deleted = 0
while True:
result, eof, info = manager.list(settings.qiniu_bucket, prefix=prefix, marker=marker, limit=1000)
if info.status_code != 200:
raise RuntimeError(f"七牛列举失败:{info.error or info.text_body or info.status_code}")
items = (result or {}).get("items") or []
for item in items:
await self.delete(item["key"])
deleted += 1
marker = (result or {}).get("marker")
if eof or not marker:
return deleted
def get_storage():
settings = get_settings()
@@ -105,6 +207,19 @@ def get_storage():
return LocalStorage()
def storage_key_from_url(url: str) -> str | None:
"""将本项目生成的公开 URL 还原为对象 key;第三方源站 URL 返回 None。"""
if not url:
return None
settings = get_settings()
if "/media/" in url:
return url.split("/media/", 1)[1].split("?", 1)[0]
domain = settings.qiniu_domain.rstrip("/")
if domain and url.startswith(f"{domain}/"):
return url[len(domain) + 1:].split("?", 1)[0]
return None
def local_path(stored_url_or_key: str) -> Path | None:
"""stored_url(http…/media/xxx)或 media key → 本地文件路径。
@@ -113,5 +228,9 @@ def local_path(stored_url_or_key: str) -> Path | None:
s = stored_url_or_key or ""
if "/media/" in s:
s = s.split("/media/", 1)[1]
p = _LOCAL_ROOT / s
root = _LOCAL_ROOT.resolve()
p = (root / s.split("?", 1)[0]).resolve()
# URL / key 可能来自历史数据,不能让 ../../ 逃出本地媒体目录。
if root not in p.parents:
return None
return p if p.is_file() else None