Files
nanocode/new-media-platform/website-new/backend/app/routers/images.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

360 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
图片资源管理 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: ![alt](path) 或 <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)