storage.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. """文件存储工具模块
  2. 支持多种存储方式:
  3. 1. 本地存储 (local) - 默认
  4. 2. 七牛云存储 (qiniu) - 推荐生产环境使用
  5. 3. 阿里云OSS (oss) - 可选
  6. 使用方式:
  7. from app.core.storage import storage
  8. # 上传文件
  9. url = storage.upload_file(file_content, "test.jpg", "images")
  10. # 上传 Base64 图片
  11. url = storage.upload_base64(base64_data, "avatar.png", "avatar")
  12. # 删除文件
  13. storage.delete_file("images/test.jpg")
  14. """
  15. import os
  16. import uuid
  17. import base64
  18. from io import BytesIO
  19. from datetime import datetime
  20. from typing import Optional, BinaryIO
  21. from pathlib import Path
  22. from app.core.config import settings
  23. # HEIC 格式的 MIME 类型
  24. HEIC_TYPES = {"image/heic", "image/heif"}
  25. def convert_heic_to_jpeg(content: bytes) -> bytes:
  26. """将 HEIC/HEIF 格式转换为 JPEG 格式"""
  27. try:
  28. import pillow_heif
  29. from PIL import Image
  30. heif_file = pillow_heif.open(BytesIO(content))
  31. image = heif_file.to_pillow()
  32. output = BytesIO()
  33. image.save(output, format="JPEG", quality=85)
  34. return output.getvalue()
  35. except ImportError:
  36. raise Exception("请安装 pillow-heif: pip install pillow-heif")
  37. def process_image_content(content: bytes, filename: str) -> tuple[bytes, str]:
  38. """处理图片内容,如果是 HEIC 格式则转换为 JPEG"""
  39. import imghdr
  40. img_type = imghdr.what(None, h=content[:32])
  41. if img_type in ("heic", "heif"):
  42. return convert_heic_to_jpeg(content), filename.rsplit(".", 1)[0] + ".jpg"
  43. return content, filename
  44. class BaseStorage:
  45. """存储基类"""
  46. def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
  47. """上传文件"""
  48. raise NotImplementedError
  49. def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
  50. """上传 Base64 编码的图片"""
  51. raise NotImplementedError
  52. def delete_file(self, file_path: str) -> bool:
  53. """删除文件"""
  54. raise NotImplementedError
  55. def get_url(self, file_path: str) -> str:
  56. """获取文件访问URL"""
  57. raise NotImplementedError
  58. class LocalStorage(BaseStorage):
  59. """本地存储"""
  60. def __init__(self):
  61. self.base_url = settings.LOCAL_STORAGE_URL.rstrip("/")
  62. self.upload_dir = settings.LOCAL_UPLOAD_DIR
  63. def _ensure_dir(self, folder: str) -> Path:
  64. """确保目录存在"""
  65. path = Path(self.upload_dir) / folder / datetime.now().strftime("%Y%m")
  66. path.mkdir(parents=True, exist_ok=True)
  67. return path
  68. def _generate_filename(self, filename: str) -> str:
  69. """生成唯一文件名"""
  70. ext = os.path.splitext(filename)[1] or ""
  71. unique_name = f"{uuid.uuid4().hex}{ext}"
  72. return unique_name
  73. def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
  74. """上传文件到本地"""
  75. # 处理 HEIC 格式图片
  76. file_content, filename = process_image_content(file_content, filename)
  77. folder_path = self._ensure_dir(folder)
  78. unique_filename = self._generate_filename(filename)
  79. file_path = folder_path / unique_filename
  80. with open(file_path, "wb") as f:
  81. f.write(file_content)
  82. relative_path = f"{folder}/{datetime.now().strftime('%Y%m')}/{unique_filename}" if folder else f"{datetime.now().strftime('%Y%m')}/{unique_filename}"
  83. return self.get_url(relative_path)
  84. def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
  85. """上传 Base64 图片"""
  86. # 去除 data:image/png;base64, 等前缀
  87. if "," in base64_data:
  88. base64_data = base64_data.split(",")[1]
  89. file_content = base64.b64decode(base64_data)
  90. return self.upload_file(file_content, filename, folder)
  91. def delete_file(self, file_path: str) -> bool:
  92. """删除本地文件"""
  93. try:
  94. # 移除 URL 前缀获取相对路径
  95. if file_path.startswith(self.base_url):
  96. relative_path = file_path.replace(self.base_url, "").lstrip("/")
  97. else:
  98. relative_path = file_path.lstrip("/")
  99. file_full_path = Path(self.upload_dir) / relative_path
  100. if file_full_path.exists():
  101. file_full_path.unlink()
  102. return True
  103. return False
  104. except Exception:
  105. return False
  106. def get_url(self, file_path: str) -> str:
  107. """获取文件访问URL"""
  108. return f"{self.base_url}/{file_path}"
  109. class QiniuStorage(BaseStorage):
  110. """七牛云存储"""
  111. def __init__(self):
  112. try:
  113. from qiniu import Auth, put_data, delete
  114. self.qiniu = Auth(settings.QINIU_ACCESS_KEY, settings.QINIU_SECRET_KEY)
  115. self.bucket = settings.QINIU_BUCKET
  116. self.base_url = settings.QINIU_DOMAIN.rstrip("/")
  117. except ImportError:
  118. raise ImportError("请安装七牛云 SDK: pip install qiniu")
  119. def _generate_filename(self, filename: str) -> str:
  120. """生成唯一文件名"""
  121. ext = os.path.splitext(filename)[1] or ""
  122. unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
  123. return unique_name
  124. def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
  125. """上传文件到七牛云"""
  126. from qiniu import put_data
  127. # 处理 HEIC 格式图片
  128. file_content, filename = process_image_content(file_content, filename)
  129. key = self._generate_filename(filename)
  130. token = self.qiniu.upload_token(self.bucket, key, 3600)
  131. ret, info = put_data(token, key, file_content)
  132. if ret is not None:
  133. return self.get_url(ret["key"])
  134. raise Exception(f"七牛云上传失败: {info}")
  135. def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
  136. """上传 Base64 图片到七牛云"""
  137. if "," in base64_data:
  138. base64_data = base64_data.split(",")[1]
  139. file_content = base64.b64decode(base64_data)
  140. return self.upload_file(file_content, filename, folder)
  141. def delete_file(self, file_path: str) -> bool:
  142. """删除七牛云文件"""
  143. from qiniu import delete
  144. try:
  145. key = file_path.replace(f"{self.base_url}/", "")
  146. ret, info = delete(self.bucket, key)
  147. return ret is not None
  148. except Exception:
  149. return False
  150. def get_url(self, file_path: str) -> str:
  151. """获取文件访问URL"""
  152. return f"{self.base_url}/{file_path}"
  153. class OSSStorage(BaseStorage):
  154. """阿里云OSS存储"""
  155. def __init__(self):
  156. try:
  157. import oss2
  158. self.auth = oss2.Auth(settings.OSS_ACCESS_KEY_ID, settings.OSS_ACCESS_KEY_SECRET)
  159. self.bucket_name = settings.OSS_BUCKET
  160. self.bucket = oss2.Bucket(self.auth, settings.OSS_ENDPOINT, self.bucket_name)
  161. self.base_url = settings.OSS_DOMAIN.rstrip("/")
  162. except ImportError:
  163. raise ImportError("请安装阿里云 OSS SDK: pip install oss2")
  164. def _generate_filename(self, filename: str) -> str:
  165. """生成唯一文件名"""
  166. ext = os.path.splitext(filename)[1] or ""
  167. unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
  168. return unique_name
  169. def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
  170. """上传文件到OSS"""
  171. # 处理 HEIC 格式图片
  172. file_content, filename = process_image_content(file_content, filename)
  173. key = self._generate_filename(filename)
  174. result = self.bucket.put_object(key, file_content)
  175. if result.status == 200:
  176. return self.get_url(key)
  177. raise Exception(f"OSS上传失败: {result.status}")
  178. def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
  179. """上传 Base64 图片到OSS"""
  180. if "," in base64_data:
  181. base64_data = base64_data.split(",")[1]
  182. file_content = base64.b64decode(base64_data)
  183. return self.upload_file(file_content, filename, folder)
  184. def delete_file(self, file_path: str) -> bool:
  185. """删除OSS文件"""
  186. try:
  187. key = file_path.replace(f"{self.base_url}/", "")
  188. result = self.bucket.delete_object(key)
  189. return result.status == 204
  190. except Exception:
  191. return False
  192. def get_url(self, file_path: str) -> str:
  193. """获取文件访问URL"""
  194. return f"{self.base_url}/{file_path}"
  195. class MinIOStorage(BaseStorage):
  196. """MinIO存储"""
  197. def __init__(self):
  198. try:
  199. from minio import Minio
  200. self.client = Minio(
  201. settings.MINIO_ENDPOINT,
  202. access_key=settings.MINIO_ACCESS_KEY,
  203. secret_key=settings.MINIO_SECRET_KEY,
  204. secure=settings.MINIO_SECURE,
  205. )
  206. self.bucket_name = settings.MINIO_BUCKET
  207. if settings.MINIO_BASE_URL:
  208. self.base_url = settings.MINIO_BASE_URL.rstrip("/")
  209. else:
  210. scheme = "https" if settings.MINIO_SECURE else "http"
  211. self.base_url = f"{scheme}://{settings.MINIO_ENDPOINT}/{self.bucket_name}"
  212. except ImportError:
  213. raise ImportError("请安装 MinIO SDK: pip install minio")
  214. def _generate_filename(self, filename: str) -> str:
  215. """生成唯一文件名"""
  216. ext = os.path.splitext(filename)[1] or ""
  217. unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
  218. return unique_name
  219. def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
  220. """上传文件到MinIO"""
  221. # 处理 HEIC 格式图片
  222. file_content, filename = process_image_content(file_content, filename)
  223. key = self._generate_filename(filename)
  224. if folder:
  225. key = f"{folder.strip('/')}/{key}"
  226. data = BytesIO(file_content)
  227. self.client.put_object(
  228. bucket_name=self.bucket_name,
  229. object_name=key,
  230. data=data,
  231. length=len(file_content),
  232. )
  233. return self.get_url(key)
  234. def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
  235. """上传 Base64 图片到MinIO"""
  236. if "," in base64_data:
  237. base64_data = base64_data.split(",")[1]
  238. file_content = base64.b64decode(base64_data)
  239. return self.upload_file(file_content, filename, folder)
  240. def delete_file(self, file_path: str) -> bool:
  241. """删除MinIO文件"""
  242. try:
  243. key = file_path.replace(f"{self.base_url}/", "")
  244. self.client.remove_object(self.bucket_name, key)
  245. return True
  246. except Exception:
  247. return False
  248. def get_url(self, file_path: str) -> str:
  249. """获取文件访问URL"""
  250. return f"{self.base_url}/{file_path}"
  251. def get_storage() -> BaseStorage:
  252. """获取存储实例"""
  253. storage_type = settings.STORAGE_TYPE.lower()
  254. if storage_type == "qiniu":
  255. return QiniuStorage()
  256. elif storage_type == "oss":
  257. return OSSStorage()
  258. elif storage_type == "minio":
  259. return MinIOStorage()
  260. else:
  261. return LocalStorage()
  262. # 全局存储实例
  263. storage = get_storage()