第一次提交

This commit is contained in:
2026-08-01 16:50:55 +08:00
commit c8f9e39039
68 changed files with 6016 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
"""对象存储(S3 兼容):上传/下载图片,含校验与去重。
boto3 是同步库,直接在 async 路径里调用会阻塞事件循环(传大图时整个服务卡住),
所以对外暴露 async 版本(to_thread 包装),同步版本保留给测试和脚本用。
"""
import asyncio
import hashlib
import io
import uuid
import boto3
from botocore.config import Config
from PIL import Image as PILImage
from app.config import settings
# Pillow 格式 → 扩展名
_FORMAT_EXT = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
# 解压炸弹防护:单边像素上限
_MAX_DIMENSION = 12000
class StorageError(RuntimeError):
pass
class ImageValidationError(ValueError):
pass
def _s3_client():
if not settings.s3_bucket:
raise StorageError("S3 未配置(S3_BUCKET 为空)")
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
region_name=settings.s3_region,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
config=Config(signature_version="s3v4"),
)
def validate_image(data: bytes) -> tuple[str, str]:
"""校验是真实图片且格式在白名单内。返回 (mime, ext)。"""
if len(data) > settings.max_upload_bytes:
raise ImageValidationError("文件超过大小上限")
try:
img = PILImage.open(io.BytesIO(data))
img.verify() # 校验完整性(魔数级)
# verify 后需重新打开才能读属性
img = PILImage.open(io.BytesIO(data))
except Exception as e: # noqa: BLE001
raise ImageValidationError(f"不是有效的图片文件:{e}") from e
fmt = img.format or ""
if fmt not in _FORMAT_EXT:
raise ImageValidationError(f"不支持的图片格式:{fmt}")
if img.width > _MAX_DIMENSION or img.height > _MAX_DIMENSION:
raise ImageValidationError("图片尺寸过大")
mime = f"image/{'jpeg' if fmt == 'JPEG' else fmt.lower()}"
if mime not in settings.allowed_image_mimes:
raise ImageValidationError(f"不支持的 MIME{mime}")
return mime, _FORMAT_EXT[fmt]
def upload_image(user_id: str, data: bytes, ext: str, mime: str) -> str:
"""上传到 S3,返回 object_key。"""
key = f"{user_id}/{uuid.uuid4().hex}.{ext}"
client = _s3_client()
client.put_object(
Bucket=settings.s3_bucket,
Key=key,
Body=data,
ContentType=mime,
)
return key
def download_image(object_key: str) -> bytes:
client = _s3_client()
resp = client.get_object(Bucket=settings.s3_bucket, Key=object_key)
return resp["Body"].read()
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
# ---- async 包装:在 async 路径里请用这两个 ----
async def upload_image_async(user_id: str, data: bytes, ext: str, mime: str) -> str:
return await asyncio.to_thread(upload_image, user_id, data, ext, mime)
async def download_image_async(object_key: str) -> bytes:
return await asyncio.to_thread(download_image, object_key)