123 lines
4.0 KiB
Python
123 lines
4.0 KiB
Python
# app/routers/upload.py
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from PIL import Image as PILImage
|
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
|
|
from pydantic import BaseModel
|
|
|
|
from app.config import get_settings
|
|
from app.models.admin import Admin
|
|
from app.auth import get_current_admin
|
|
|
|
router = APIRouter()
|
|
settings = get_settings()
|
|
|
|
# Allowed file types (MIME)
|
|
ALLOWED_IMAGE_TYPES = {
|
|
"image/jpeg", "image/png", "image/gif", "image/webp",
|
|
"image/x-icon", "image/vnd.microsoft.icon", # .ico favicon
|
|
"image/svg+xml", # .svg
|
|
}
|
|
# Allowed extensions (fallback when browser reports wrong MIME)
|
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".ico", ".svg"}
|
|
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE # 5MB
|
|
|
|
|
|
# Schemas
|
|
class UploadResponse(BaseModel):
|
|
code: int = 200
|
|
data: dict = None
|
|
message: str = "success"
|
|
|
|
|
|
def create_thumbnail(image_path: Path, thumb_path: Path, max_size: int = 300):
|
|
"""创建缩略图"""
|
|
try:
|
|
with PILImage.open(image_path) as img:
|
|
# 转换为 RGB(处理透明图片)
|
|
if img.mode in ('RGBA', 'P'):
|
|
img = img.convert('RGB')
|
|
|
|
# 等比缩放到最大尺寸
|
|
img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
|
|
|
|
# 保存
|
|
img.save(thumb_path, "JPEG", quality=85)
|
|
return True
|
|
except Exception as e:
|
|
print(f"创建缩略图失败: {e}")
|
|
return False
|
|
|
|
|
|
@router.post("/upload/image", response_model=UploadResponse)
|
|
async def upload_image(
|
|
file: UploadFile = File(...),
|
|
subdir: str = Form(default=""),
|
|
current_admin = Depends(get_current_admin)
|
|
):
|
|
"""Upload image file - 支持按子目录存储"""
|
|
|
|
# Check file type - allow by MIME or by extension (browsers may report wrong MIME for .ico)
|
|
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
|
if file.content_type not in ALLOWED_IMAGE_TYPES and file_ext not in ALLOWED_EXTENSIONS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"不支持的文件类型: {file.content_type} ({file_ext})"
|
|
)
|
|
|
|
# Check file size
|
|
content = await file.read()
|
|
if len(content) > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"文件大小超过限制: {MAX_FILE_SIZE / 1024 / 1024}MB"
|
|
)
|
|
|
|
# Generate unique filename with timestamp
|
|
ext = os.path.splitext(file.filename)[1].lower()
|
|
if not ext:
|
|
ext = ".jpg" if file.content_type == "image/jpeg" else ".png"
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
|
|
|
# 根据子目录存储
|
|
upload_path = Path(settings.UPLOAD_DIR)
|
|
if subdir:
|
|
upload_path = upload_path / subdir
|
|
upload_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 缩略图目录(始终放在 thumbnails 下)
|
|
thumbnail_dir = Path(settings.UPLOAD_DIR) / "thumbnails"
|
|
thumbnail_dir.mkdir(exist_ok=True)
|
|
|
|
# Save file
|
|
file_path = upload_path / filename
|
|
with open(file_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
# 创建缩略图(.ico 和 .svg 跳过缩略图生成)
|
|
thumbnail_name = f"{Path(filename).stem}.jpg"
|
|
thumb_path = thumbnail_dir / thumbnail_name
|
|
thumb_relative_path = None
|
|
if ext not in ('.ico', '.svg') and create_thumbnail(file_path, thumb_path):
|
|
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
|
|
|
|
# Return URL
|
|
if subdir:
|
|
url = f"/storage/admin/images/{subdir}/{filename}"
|
|
else:
|
|
url = f"/storage/admin/images/{filename}"
|
|
|
|
return UploadResponse(
|
|
message="上传成功",
|
|
data={
|
|
"url": url,
|
|
"filename": filename,
|
|
"size": len(content),
|
|
"thumb_url": thumb_relative_path
|
|
}
|
|
)
|