oss_service.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """
  2. OSS 上传服务
  3. """
  4. import oss2
  5. import io
  6. from typing import Optional
  7. from urllib.parse import quote
  8. from datetime import datetime, timezone
  9. from PIL import Image
  10. from utils.config import settings
  11. from utils.logger import logger
  12. from utils.crypto import encrypt_url, decrypt_url
  13. class OSSService:
  14. def __init__(self):
  15. auth = oss2.Auth(settings.oss.access_key_id, settings.oss.access_key_secret)
  16. self.bucket = oss2.Bucket(auth, settings.oss.endpoint, settings.oss.bucket)
  17. self.encrypt_key = settings.oss.parse_encrypt_key
  18. logger.info(
  19. "OSSService 初始化完成: endpoint=%s, bucket=%s, access_key_id=%s",
  20. settings.oss.endpoint,
  21. settings.oss.bucket,
  22. f"{settings.oss.access_key_id[:4]}***" if settings.oss.access_key_id else "EMPTY",
  23. )
  24. def _build_public_url(self, object_name: str) -> str:
  25. """
  26. 构建 OSS 公网直链。
  27. 与 Go 版本保持一致:
  28. - 使用 endpoint/bucket/object_name 的永久直链格式
  29. - object_name 做 URL 编码,避免中文/空格导致图片无法访问
  30. """
  31. endpoint = settings.oss.endpoint.rstrip("/")
  32. bucket = settings.oss.bucket
  33. if endpoint.startswith("http://") or endpoint.startswith("https://"):
  34. base_url = f"{endpoint}/{bucket}"
  35. else:
  36. base_url = f"https://{bucket}.{endpoint}"
  37. encoded_object_name = "/".join(
  38. quote(part, safe="")
  39. for part in object_name.split("/")
  40. )
  41. public_url = f"{base_url}/{encoded_object_name}"
  42. logger.info(
  43. "构建 OSS 公网 URL 完成: object_name=%s, base_url=%s, encoded_object_name=%s, public_url=%s",
  44. object_name,
  45. base_url,
  46. encoded_object_name,
  47. public_url,
  48. )
  49. return public_url
  50. def _compress_image_to_jpeg(self, file_data: bytes) -> bytes:
  51. """
  52. 按 Go 版本思路压缩图片:
  53. - 尝试转成 JPEG
  54. - 优先通过质量压缩
  55. - 失败时回退原始数据
  56. """
  57. try:
  58. original_size = len(file_data)
  59. logger.info("开始压缩图片: original_size=%s bytes", original_size)
  60. img = Image.open(io.BytesIO(file_data))
  61. logger.info(
  62. "图片打开成功: format=%s, mode=%s, size=%sx%s",
  63. getattr(img, "format", "unknown"),
  64. img.mode,
  65. img.size[0],
  66. img.size[1],
  67. )
  68. img = img.convert("RGB")
  69. out = io.BytesIO()
  70. img.save(out, format="JPEG", quality=85, optimize=True)
  71. compressed = out.getvalue()
  72. logger.info(
  73. "图片压缩完成: compressed_size=%s bytes, ratio=%.2f%%",
  74. len(compressed),
  75. (len(compressed) / original_size * 100) if original_size else 0,
  76. )
  77. # 只有压缩有效时才采用,否则回退原图
  78. if compressed and len(compressed) < original_size:
  79. logger.info("采用压缩后的图片数据上传")
  80. return compressed
  81. logger.info("压缩后未明显变小,回退原图上传")
  82. return compressed or file_data
  83. except Exception as e:
  84. logger.warning(f"图片压缩失败,回退原图: {e}")
  85. return file_data
  86. def upload_file(self, file_data: bytes, filename: str, folder: str = "uploads") -> str:
  87. """上传文件到OSS"""
  88. try:
  89. object_name = f"{folder}/{filename}"
  90. logger.info(
  91. "准备上传文件: folder=%s, filename=%s, object_name=%s, size=%s bytes",
  92. folder,
  93. filename,
  94. object_name,
  95. len(file_data),
  96. )
  97. result = self.bucket.put_object(object_name, file_data)
  98. logger.info(
  99. "OSS 上传响应: object_name=%s, status=%s, request_id=%s, etag=%s",
  100. object_name,
  101. getattr(result, "status", None),
  102. getattr(result, "request_id", None),
  103. getattr(result, "etag", None),
  104. )
  105. if result.status == 200:
  106. file_url = self._build_public_url(object_name)
  107. logger.info(
  108. "文件上传成功: object_name=%s, public_url=%s",
  109. object_name,
  110. file_url,
  111. )
  112. return file_url
  113. else:
  114. logger.error(
  115. "文件上传失败: object_name=%s, status=%s, response=%s",
  116. object_name,
  117. result.status,
  118. result,
  119. )
  120. raise Exception(f"上传失败,状态码: {result.status}")
  121. except Exception as e:
  122. logger.error(f"OSS上传失败: {e}", exc_info=True)
  123. raise
  124. def upload_image(self, file_data: bytes, filename: str) -> str:
  125. """
  126. 上传图片,行为对齐 Go 版本:
  127. - 使用 images/YYYY/MMdd_timestamp.jpg 命名
  128. - 做基础 JPEG 压缩
  129. - 使用 public-read 直链访问
  130. - 返回可直接访问的公网 URL
  131. """
  132. try:
  133. now = datetime.now(timezone.utc)
  134. timestamp = int(now.timestamp())
  135. object_name = f"images/{now.year}/{now.strftime('%m%d')}_{timestamp}.jpg"
  136. logger.info(
  137. "准备上传图片: original_filename=%s, object_name=%s, timestamp=%s, utc_now=%s",
  138. filename,
  139. object_name,
  140. timestamp,
  141. now.isoformat(),
  142. )
  143. compressed_data = self._compress_image_to_jpeg(file_data)
  144. logger.info(
  145. "图片数据准备完成: object_name=%s, upload_size=%s bytes",
  146. object_name,
  147. len(compressed_data),
  148. )
  149. result = self.bucket.put_object(
  150. object_name,
  151. compressed_data,
  152. headers={"x-oss-object-acl": "public-read"}
  153. )
  154. logger.info(
  155. "OSS 图片上传响应: object_name=%s, status=%s, request_id=%s, etag=%s",
  156. object_name,
  157. getattr(result, "status", None),
  158. getattr(result, "request_id", None),
  159. getattr(result, "etag", None),
  160. )
  161. if result.status == 200:
  162. public_url = self._build_public_url(object_name)
  163. logger.info(
  164. "图片上传成功: object_name=%s, public_url=%s",
  165. object_name,
  166. public_url,
  167. )
  168. return public_url
  169. logger.error(
  170. "图片上传失败: object_name=%s, status=%s, response=%s",
  171. object_name,
  172. result.status,
  173. result,
  174. )
  175. raise Exception(f"上传图片失败,状态码: {result.status}")
  176. except Exception as e:
  177. logger.error(f"OSS图片上传失败: {e}", exc_info=True)
  178. raise
  179. def upload_json(self, json_data: str, filename: str) -> str:
  180. """上传JSON文件"""
  181. return self.upload_file(json_data.encode("utf-8"), filename, folder="json")
  182. def get_file_url(self, object_name: str, expires: int = 3600) -> str:
  183. """获取文件访问URL"""
  184. try:
  185. url = self.bucket.sign_url("GET", object_name, expires)
  186. return url
  187. except Exception as e:
  188. logger.error(f"获取文件URL失败: {e}")
  189. raise
  190. def parse_url(self, encrypted_url: str) -> str:
  191. """解析加密的URL"""
  192. try:
  193. return decrypt_url(encrypted_url)
  194. except Exception as e:
  195. logger.error(f"URL解析失败: {e}")
  196. raise
  197. def get_signed_url(self, filename: str, expires: int = 3600) -> str:
  198. """获取签名URL"""
  199. try:
  200. object_name = f"shudao/{filename}"
  201. url = self.bucket.sign_url("GET", object_name, expires)
  202. return url
  203. except Exception as e:
  204. logger.error(f"获取签名URL失败: {e}")
  205. raise
  206. # 全局实例
  207. oss_service = OSSService()