"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。""" 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()