Files

106 lines
3.8 KiB
Python
Raw Permalink 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 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()