360 lines
11 KiB
Python
360 lines
11 KiB
Python
"""
|
||
图片资源管理 API 路由
|
||
复用自 personal-blog,添加 module 字段支持多模块共用
|
||
图片扁平存储在 /uploads 目录下,缩略图在 /uploads/thumbnails/
|
||
"""
|
||
import os
|
||
import re
|
||
import uuid
|
||
import shutil
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||
from fastapi.responses import FileResponse
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import or_
|
||
from PIL import Image as PILImage
|
||
|
||
from app.database import get_db
|
||
from app.models.image import Image
|
||
from app.models.article import Article
|
||
from app.routers.auth import get_current_admin
|
||
from app.config import get_settings
|
||
from app.utils.file_utils import cleanup_file_by_path
|
||
|
||
router = APIRouter(prefix="/images", tags=["images"], redirect_slashes=False)
|
||
|
||
# 上传配置 - 使用与静态文件服务相同的目录
|
||
settings = get_settings()
|
||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 缩略图目录
|
||
THUMBNAIL_DIR = UPLOAD_DIR / "thumbnails"
|
||
THUMBNAIL_DIR.mkdir(exist_ok=True)
|
||
|
||
# 允许的图片格式
|
||
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||
|
||
|
||
def generate_filename(original_name: str) -> str:
|
||
"""生成唯一文件名"""
|
||
ext = Path(original_name).suffix.lower()
|
||
if ext not in ALLOWED_EXTENSIONS:
|
||
raise HTTPException(status_code=400, detail=f"不支持的文件格式,允许: {', '.join(ALLOWED_EXTENSIONS)}")
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
unique_id = uuid.uuid4().hex[:8]
|
||
return f"{timestamp}_{unique_id}{ext}"
|
||
|
||
|
||
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.get("")
|
||
@router.get("/")
|
||
def list_images(
|
||
page: int = Query(default=1, ge=1),
|
||
size: int = Query(default=20, ge=1, le=100),
|
||
module: Optional[str] = Query(default=None, description="按模块筛选"),
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""获取图片列表(同时支持 /api/images 和 /api/images/)"""
|
||
query = db.query(Image).order_by(Image.created_at.desc())
|
||
|
||
if module:
|
||
query = query.filter(Image.module == module)
|
||
|
||
total = query.count()
|
||
images = query.offset((page - 1) * size).limit(size).all()
|
||
|
||
return {
|
||
"items": [img.to_dict() for img in images],
|
||
"total": total,
|
||
"page": page,
|
||
"size": size
|
||
}
|
||
|
||
|
||
@router.post("/upload")
|
||
async def upload_image(
|
||
file: UploadFile = File(...),
|
||
description: Optional[str] = Form(default=None),
|
||
module: str = Form(default="news"),
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""上传图片 - 扁平存储在 /uploads 目录"""
|
||
# 检查文件大小
|
||
file_size = 0
|
||
content = await file.read()
|
||
file_size = len(content)
|
||
|
||
if file_size > MAX_FILE_SIZE:
|
||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_FILE_SIZE / 1024 / 1024}MB)")
|
||
|
||
# 生成文件名
|
||
try:
|
||
filename = generate_filename(file.filename)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(status_code=400, detail="文件名处理失败")
|
||
|
||
# 扁平存储 - 直接放在 /uploads 目录下
|
||
file_path = UPLOAD_DIR / filename
|
||
with open(file_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
# 获取图片尺寸
|
||
width, height = None, None
|
||
try:
|
||
with PILImage.open(file_path) as img:
|
||
width, height = img.size
|
||
except Exception:
|
||
pass
|
||
|
||
# 创建缩略图 - 统一放在 thumbnails 目录下
|
||
# 缩略图与原图同名,但扩展名统一为 .jpg
|
||
thumbnail_name = f"{Path(filename).stem}.jpg"
|
||
thumb_file_path = THUMBNAIL_DIR / thumbnail_name
|
||
|
||
thumb_relative_path = None
|
||
if create_thumbnail(file_path, thumb_file_path):
|
||
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
|
||
|
||
# 保存到数据库 - 使用新的存储路径
|
||
relative_file_path = f"/storage/admin/images/{filename}"
|
||
|
||
image = Image(
|
||
filename=filename,
|
||
original_name=file.filename,
|
||
file_path=relative_file_path,
|
||
thumb_path=thumb_relative_path,
|
||
description=description,
|
||
file_size=file_size,
|
||
width=width,
|
||
height=height,
|
||
module=module
|
||
)
|
||
|
||
db.add(image)
|
||
db.commit()
|
||
db.refresh(image)
|
||
|
||
return {
|
||
"success": True,
|
||
"data": image.to_dict()
|
||
}
|
||
|
||
|
||
@router.delete("/{image_id}")
|
||
def delete_image(
|
||
image_id: int,
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""删除图片"""
|
||
image = db.query(Image).filter(Image.id == image_id).first()
|
||
if not image:
|
||
raise HTTPException(status_code=404, detail="图片不存在")
|
||
|
||
# 删除物理文件及其缩略图
|
||
cleanup_file_by_path(image.file_path)
|
||
|
||
db.delete(image)
|
||
db.commit()
|
||
|
||
return {"message": "删除成功"}
|
||
|
||
|
||
@router.put("/{image_id}")
|
||
def update_image(
|
||
image_id: int,
|
||
description: Optional[str] = Form(default=None),
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""更新图片描述"""
|
||
image = db.query(Image).filter(Image.id == image_id).first()
|
||
if not image:
|
||
raise HTTPException(status_code=404, detail="图片不存在")
|
||
|
||
if description is not None:
|
||
image.description = description
|
||
|
||
db.commit()
|
||
db.refresh(image)
|
||
return image.to_dict()
|
||
|
||
|
||
@router.get("/{image_id}/insert")
|
||
def get_image_insert_info(
|
||
image_id: int,
|
||
size: str = Query(default="original", pattern="^(original|thumb)$"),
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""获取图片插入信息(用于编辑器插入)"""
|
||
image = db.query(Image).filter(Image.id == image_id).first()
|
||
if not image:
|
||
raise HTTPException(status_code=404, detail="图片不存在")
|
||
|
||
url = image.file_path if size == "original" else (image.thumb_path or image.file_path)
|
||
|
||
return {
|
||
"id": image.id,
|
||
"url": url,
|
||
"width": image.width,
|
||
"height": image.height,
|
||
"original_name": image.original_name
|
||
}
|
||
|
||
|
||
@router.post("/batch-delete")
|
||
def batch_delete_images(
|
||
image_ids: list[int],
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""批量删除图片"""
|
||
deleted_count = 0
|
||
|
||
for image_id in image_ids:
|
||
image = db.query(Image).filter(Image.id == image_id).first()
|
||
if image:
|
||
# 删除物理文件及其缩略图
|
||
cleanup_file_by_path(image.file_path)
|
||
|
||
db.delete(image)
|
||
deleted_count += 1
|
||
|
||
db.commit()
|
||
|
||
return {"message": f"成功删除 {deleted_count} 张图片"}
|
||
|
||
|
||
def _collect_referenced_paths(db: Session) -> set:
|
||
"""收集所有被文章引用的图片路径(封面图 + 内容中的图片)。
|
||
|
||
优化:仅加载有图片引用的文章,跳过无封面且内容无图片路径的文章。
|
||
"""
|
||
referenced_paths = set()
|
||
|
||
# 预过滤:只查有 cover_image 或内容中可能包含图片的文章
|
||
articles = db.query(Article).filter(
|
||
or_(
|
||
Article.cover_image.isnot(None),
|
||
Article.cover_image != '',
|
||
Article.content.like('%/storage/%'),
|
||
Article.content.like('%/uploads/%'),
|
||
Article.content.like('%.jpg%'),
|
||
Article.content.like('%.png%'),
|
||
Article.content.like('%.gif%'),
|
||
Article.content.like('%.webp%'),
|
||
)
|
||
).all()
|
||
|
||
for post in articles:
|
||
if post.cover_image:
|
||
referenced_paths.add(post.cover_image)
|
||
if post.content:
|
||
# Markdown:  或 <img src="path">
|
||
md_images = re.findall(r'!\[.*?\]\((.*?)\)', post.content)
|
||
html_images = re.findall(r'<img[^>]+src=["\']([^"\']+)["\']', post.content)
|
||
for path in md_images + html_images:
|
||
referenced_paths.add(path)
|
||
|
||
return referenced_paths
|
||
|
||
|
||
@router.get("/unreferenced")
|
||
def get_unreferenced_images(
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""扫描未被任何文章引用的图片"""
|
||
referenced_paths = _collect_referenced_paths(db)
|
||
|
||
# 仅获取有 file_path 的图片
|
||
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
|
||
|
||
# 找出未引用的图片
|
||
unreferenced = []
|
||
for img in all_images:
|
||
if img.file_path not in referenced_paths:
|
||
unreferenced.append(img.to_dict())
|
||
|
||
return {
|
||
"items": unreferenced,
|
||
"total": len(unreferenced)
|
||
}
|
||
|
||
|
||
@router.post("/cleanup")
|
||
def cleanup_unreferenced_images(
|
||
db: Session = Depends(get_db),
|
||
current_admin=Depends(get_current_admin)
|
||
):
|
||
"""批量删除未被任何文章引用的图片"""
|
||
referenced_paths = _collect_referenced_paths(db)
|
||
|
||
# 仅获取有 file_path 的图片
|
||
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
|
||
|
||
# 删除未引用的图片
|
||
deleted_count = 0
|
||
deleted_files = []
|
||
for img in all_images:
|
||
if img.file_path not in referenced_paths:
|
||
cleanup_file_by_path(img.file_path)
|
||
deleted_files.append(img.to_dict())
|
||
db.delete(img)
|
||
deleted_count += 1
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"message": f"成功清理 {deleted_count} 张未引用图片",
|
||
"deleted_count": deleted_count,
|
||
"deleted_files": deleted_files
|
||
}
|
||
|
||
|
||
# 静态文件服务(开发环境使用,生产环境建议用 Nginx)
|
||
@router.get("/file/{path:path}")
|
||
def serve_image(path: str):
|
||
"""提供图片文件访问"""
|
||
file_path = UPLOAD_DIR / path
|
||
|
||
if not file_path.exists():
|
||
raise HTTPException(status_code=404, detail="文件不存在")
|
||
|
||
# 安全检查,防止目录遍历
|
||
try:
|
||
file_path.resolve().relative_to(UPLOAD_DIR.resolve())
|
||
except ValueError:
|
||
raise HTTPException(status_code=403, detail="禁止访问")
|
||
|
||
return FileResponse(file_path)
|