2026-03-23 11:08:52 +08:00
|
|
|
|
# 阿里云OSS服务
|
|
|
|
|
|
import os
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import datetime
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
import oss2
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
import io
|
2026-04-12 14:24:48 +08:00
|
|
|
|
from app.core.config import settings
|
2026-03-23 11:08:52 +08:00
|
|
|
|
|
|
|
|
|
|
# 图片压缩配置
|
|
|
|
|
|
IMAGE_CONFIG = {
|
|
|
|
|
|
"max_size": 1024 * 1024, # 1MB
|
|
|
|
|
|
"max_width": 2048,
|
|
|
|
|
|
"max_height": 2048,
|
|
|
|
|
|
"quality": 85,
|
|
|
|
|
|
"format": "JPEG"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 14:24:48 +08:00
|
|
|
|
# 懒加载OSS连接
|
|
|
|
|
|
_oss_bucket = None
|
|
|
|
|
|
|
|
|
|
|
|
def _get_oss_bucket():
|
|
|
|
|
|
"""获取OSS Bucket实例(懒加载)"""
|
|
|
|
|
|
global _oss_bucket
|
|
|
|
|
|
if _oss_bucket is None:
|
|
|
|
|
|
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
|
|
|
|
|
raise ValueError("OSS配置未设置,请检查环境变量 OSS_ACCESS_KEY_ID 和 OSS_ACCESS_KEY_SECRET")
|
|
|
|
|
|
auth = oss2.Auth(settings.OSS_ACCESS_KEY_ID, settings.OSS_ACCESS_KEY_SECRET)
|
|
|
|
|
|
_oss_bucket = oss2.Bucket(auth, settings.OSS_ENDPOINT, settings.OSS_BUCKET)
|
|
|
|
|
|
return _oss_bucket
|
2026-03-23 11:08:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str:
|
|
|
|
|
|
"""生成OSS存储路径"""
|
|
|
|
|
|
now = datetime.datetime.now()
|
|
|
|
|
|
year = now.strftime("%Y")
|
|
|
|
|
|
month = now.strftime("%m")
|
|
|
|
|
|
day = now.strftime("%d")
|
|
|
|
|
|
|
|
|
|
|
|
if filename:
|
|
|
|
|
|
ext = filename.split('.')[-1] if '.' in filename else 'jpg'
|
|
|
|
|
|
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
unique_name = f"{uuid.uuid4().hex}.jpg"
|
|
|
|
|
|
|
|
|
|
|
|
if user_id:
|
|
|
|
|
|
path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
path = f"{prefix}/{year}/{month}/{day}/{unique_name}"
|
|
|
|
|
|
|
|
|
|
|
|
return path, unique_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str:
|
|
|
|
|
|
"""上传文件到OSS,返回公网URL"""
|
|
|
|
|
|
try:
|
2026-04-12 14:24:48 +08:00
|
|
|
|
bucket = _get_oss_bucket()
|
|
|
|
|
|
|
2026-03-23 11:08:52 +08:00
|
|
|
|
# 如果是图片,进行压缩
|
|
|
|
|
|
if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']):
|
|
|
|
|
|
file_data = compress_image(file_data)
|
|
|
|
|
|
|
|
|
|
|
|
# 上传文件
|
|
|
|
|
|
result = bucket.put_object(oss_key, file_data)
|
|
|
|
|
|
|
|
|
|
|
|
if result.status == 200:
|
|
|
|
|
|
# 返回公网URL
|
2026-04-12 14:24:48 +08:00
|
|
|
|
return f"https://{settings.OSS_BUCKET}.{settings.OSS_ENDPOINT}/{oss_key}"
|
2026-03-23 11:08:52 +08:00
|
|
|
|
else:
|
|
|
|
|
|
raise Exception(f"OSS上传失败: {result.status}")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise Exception(f"OSS上传失败: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_from_oss(oss_key: str) -> bool:
|
|
|
|
|
|
"""从OSS删除文件"""
|
|
|
|
|
|
try:
|
2026-04-12 14:24:48 +08:00
|
|
|
|
bucket = _get_oss_bucket()
|
2026-03-23 11:08:52 +08:00
|
|
|
|
result = bucket.delete_object(oss_key)
|
|
|
|
|
|
return result.status == 204
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"OSS删除失败: {str(e)}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_public_url(oss_key: str) -> str:
|
|
|
|
|
|
"""获取公网URL"""
|
2026-04-12 14:24:48 +08:00
|
|
|
|
return f"https://{settings.OSS_BUCKET}.{settings.OSS_ENDPOINT}/{oss_key}"
|
2026-03-23 11:08:52 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compress_image(image_data: bytes, max_size: int = None) -> bytes:
|
|
|
|
|
|
"""压缩图片到指定大小以内"""
|
|
|
|
|
|
if max_size is None:
|
|
|
|
|
|
max_size = IMAGE_CONFIG["max_size"]
|
|
|
|
|
|
|
|
|
|
|
|
# 如果已经小于限制,直接返回
|
|
|
|
|
|
if len(image_data) <= max_size:
|
|
|
|
|
|
return image_data
|
|
|
|
|
|
|
|
|
|
|
|
# 打开图片
|
|
|
|
|
|
img = Image.open(io.BytesIO(image_data))
|
|
|
|
|
|
|
|
|
|
|
|
# 如果是PNG且有透明通道,转换为RGB
|
|
|
|
|
|
if img.mode in ('RGBA', 'P'):
|
|
|
|
|
|
img = img.convert('RGB')
|
|
|
|
|
|
|
|
|
|
|
|
# 逐步降低质量直到达到目标大小
|
|
|
|
|
|
quality = 95
|
|
|
|
|
|
compressed_data = image_data
|
|
|
|
|
|
|
|
|
|
|
|
while quality > 30 and len(compressed_data) > max_size:
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True)
|
|
|
|
|
|
compressed_data = output.getvalue()
|
|
|
|
|
|
quality -= 10
|
|
|
|
|
|
|
|
|
|
|
|
# 如果还是太大,缩小尺寸
|
|
|
|
|
|
if len(compressed_data) > max_size:
|
|
|
|
|
|
width, height = img.size
|
|
|
|
|
|
while len(compressed_data) > max_size and width > 400:
|
|
|
|
|
|
width = int(width * 0.8)
|
|
|
|
|
|
height = int(height * 0.8)
|
|
|
|
|
|
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
output = io.BytesIO()
|
|
|
|
|
|
img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True)
|
|
|
|
|
|
compressed_data = output.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
return compressed_data
|