Files
ozon-seller-kit/server/services/storage.py
T

237 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
from __future__ import annotations
import mimetypes
import shutil
import uuid
from pathlib import Path
import httpx
from config import get_settings
# 本地存储根目录(仓库根 data/media/)
_LOCAL_ROOT = Path(__file__).resolve().parents[2] / "data" / "media"
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:
ctype = (content_type or "").split(";")[0].strip().lower()
mapping = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/bmp": ".bmp",
"image/heic": ".heic",
"video/mp4": ".mp4",
}
return mapping.get(ctype, ".jpg")
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:
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, 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:
return self.public_url(self._write(data, key, content_type))
def _write(self, data: bytes, key: str, content_type: str) -> str:
_LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
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 可拉取)。"""
def _client(self):
import qiniu
settings = get_settings()
return qiniu.Auth(settings.qiniu_access_key, settings.qiniu_secret_key), settings
async def save_from_url(self, url: str, key_prefix: str = "", referer: str | None = None) -> str:
data, ctype = await download_bytes(url, referer)
return await self.save_bytes(data, f"{key_prefix}/{uuid.uuid4().hex}{_ext_from_content_type(ctype)}", ctype)
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
import qiniu
auth, settings = self._client()
bucket = settings.qiniu_bucket
token = auth.upload_token(bucket, key, 3600)
ret, info = qiniu.put_data(token, key, data)
if info.status_code not in (200,):
raise RuntimeError(f"七牛上传失败:{info.error or info.text_body or info.status_code}")
return self.public_url(key)
def public_url(self, key: str) -> str:
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()
if settings.use_qiniu:
return QiniuStorage()
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 → 本地文件路径。
仅本地存储模式下可命中;七牛 URL 不含 /media/,返回 None 由调用方回退远程下载。
"""
s = stored_url_or_key or ""
if "/media/" in s:
s = s.split("/media/", 1)[1]
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