feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from config import get_settings
|
||||
|
||||
# 本地存储根目录(仓库根 data/media/)
|
||||
_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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
async def download_bytes(url: str, referer: str | None = None, timeout: float = 60.0) -> tuple[bytes, str]:
|
||||
"""下载远程字节。返回 (bytes, content_type)。"""
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
async def save_bytes(self, data: bytes, key: str, content_type: str = "") -> str:
|
||||
key = self._write(data, "", key)
|
||||
return self.public_url(key)
|
||||
|
||||
def _write(self, data: bytes, key_prefix: str, hint: 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
|
||||
|
||||
def public_url(self, key: str) -> str:
|
||||
settings = get_settings()
|
||||
return f"{settings.app_base_url.rstrip('/')}/media/{key}"
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
|
||||
def get_storage():
|
||||
settings = get_settings()
|
||||
if settings.use_qiniu:
|
||||
return QiniuStorage()
|
||||
return LocalStorage()
|
||||
Reference in New Issue
Block a user