import os import uuid import hashlib from PIL import Image from typing import Optional, Tuple from loguru import logger class ImageManager: """图片管理器,负责图片的保存、压缩和访问""" def __init__(self, upload_dir: str = "static/uploads/images"): """初始化图片管理器 Args: upload_dir: 图片上传目录 """ self.upload_dir = upload_dir self.max_size = 5 * 1024 * 1024 # 5MB self.max_width = 1920 self.max_height = 1080 self.allowed_formats = {'JPEG', 'PNG', 'GIF', 'WEBP'} # 确保上传目录存在 self._ensure_upload_dir() def _ensure_upload_dir(self): """确保上传目录存在""" try: os.makedirs(self.upload_dir, exist_ok=True) logger.info(f"图片上传目录已准备: {self.upload_dir}") except Exception as e: logger.error(f"创建图片上传目录失败: {e}") raise def save_image(self, image_data: bytes, original_filename: str = None) -> Optional[str]: """保存图片文件 Args: image_data: 图片二进制数据 original_filename: 原始文件名(可选) Returns: 保存成功返回相对路径,失败返回None """ try: logger.info(f"开始保存图片,数据大小: {len(image_data)} bytes") # 验证图片数据 if not self._validate_image_data(image_data): logger.error("图片数据验证失败") return None # 生成唯一文件名 file_hash = hashlib.md5(image_data).hexdigest() file_extension = self._get_image_extension(image_data) filename = f"{file_hash}_{uuid.uuid4().hex[:8]}.{file_extension}" # 完整文件路径 file_path = os.path.join(self.upload_dir, filename) # 检查文件是否已存在 if os.path.exists(file_path): logger.info(f"图片文件已存在,跳过保存: {filename}") return self._get_relative_path(file_path) # 处理和保存图片 processed_image_data = self._process_image(image_data) with open(file_path, 'wb') as f: f.write(processed_image_data) logger.info(f"图片保存成功: {filename}") return self._get_relative_path(file_path) except Exception as e: logger.error(f"保存图片失败: {e}") return None def _validate_image_data(self, image_data: bytes) -> bool: """验证图片数据""" try: # 检查文件大小 if len(image_data) > self.max_size: logger.warning(f"图片文件过大: {len(image_data)} bytes > {self.max_size} bytes") return False # 尝试打开图片验证格式 from io import BytesIO with Image.open(BytesIO(image_data)) as img: if img.format not in self.allowed_formats: logger.warning(f"不支持的图片格式: {img.format}") return False # 检查图片尺寸(允许更大的尺寸,特别是手机长截图) width, height = img.size max_dimension = 4096 # 最大边长4096像素 if width > max_dimension or height > max_dimension: logger.warning(f"图片尺寸过大: {width}x{height},最大允许: {max_dimension}x{max_dimension}") return False # 检查图片像素总数(防止过大的图片占用太多内存) total_pixels = width * height max_pixels = 8 * 1024 * 1024 # 8M像素 if total_pixels > max_pixels: logger.warning(f"图片像素总数过大: {total_pixels},最大允许: {max_pixels}") return False return True except Exception as e: logger.error(f"图片验证失败: {e}") return False def _get_image_extension(self, image_data: bytes) -> str: """获取图片扩展名""" try: from io import BytesIO with Image.open(BytesIO(image_data)) as img: format_to_ext = { 'JPEG': 'jpg', 'PNG': 'png', 'GIF': 'gif', 'WEBP': 'webp' } return format_to_ext.get(img.format, 'jpg') except: return 'jpg' def _process_image(self, image_data: bytes) -> bytes: """处理图片(压缩、调整尺寸等)""" try: from io import BytesIO with Image.open(BytesIO(image_data)) as img: # 转换为RGB模式(如果需要) if img.mode in ('RGBA', 'LA', 'P'): # 创建白色背景 background = Image.new('RGB', img.size, (255, 255, 255)) if img.mode == 'P': img = img.convert('RGBA') background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) img = background elif img.mode != 'RGB': img = img.convert('RGB') # 调整尺寸(如果需要)- 允许更大的尺寸 width, height = img.size max_output_dimension = 2048 # 输出最大边长2048像素 if width > max_output_dimension or height > max_output_dimension: # 计算缩放比例,保持宽高比 ratio = min(max_output_dimension / width, max_output_dimension / height) new_width = int(width * ratio) new_height = int(height * ratio) img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) logger.info(f"图片已调整尺寸: {width}x{height} -> {new_width}x{new_height}") else: logger.info(f"图片尺寸合适,无需调整: {width}x{height}") # 保存为JPEG格式,适度压缩 output = BytesIO() img.save(output, format='JPEG', quality=85, optimize=True) return output.getvalue() except Exception as e: logger.error(f"图片处理失败: {e}") # 如果处理失败,返回原始数据 return image_data def _get_relative_path(self, file_path: str) -> str: """获取相对于项目根目录的路径""" # 将绝对路径转换为相对路径 rel_path = os.path.relpath(file_path) # 统一使用正斜杠 return rel_path.replace('\\', '/') def delete_image(self, image_path: str) -> bool: """删除图片文件 Args: image_path: 图片相对路径 Returns: 删除成功返回True,失败返回False """ try: # 构建完整路径 if not image_path.startswith(self.upload_dir): full_path = os.path.join(os.getcwd(), image_path) else: full_path = image_path if os.path.exists(full_path): os.remove(full_path) logger.info(f"图片删除成功: {image_path}") return True else: logger.warning(f"图片文件不存在: {image_path}") return False except Exception as e: logger.error(f"删除图片失败: {e}") return False def get_image_info(self, image_path: str) -> Optional[dict]: """获取图片信息 Args: image_path: 图片相对路径 Returns: 图片信息字典或None """ try: # 构建完整路径 if not image_path.startswith(self.upload_dir): full_path = os.path.join(os.getcwd(), image_path) else: full_path = image_path if not os.path.exists(full_path): return None with Image.open(full_path) as img: return { 'width': img.width, 'height': img.height, 'format': img.format, 'mode': img.mode, 'size': os.path.getsize(full_path) } except Exception as e: logger.error(f"获取图片信息失败: {e}") return None def get_image_size(self, image_path: str) -> tuple: """获取图片尺寸 Args: image_path: 图片相对路径 Returns: (width, height) 或 (None, None) """ try: info = self.get_image_info(image_path) if info: return info['width'], info['height'] return None, None except Exception as e: logger.error(f"获取图片尺寸失败: {e}") return None, None # 创建全局图片管理器实例 image_manager = ImageManager()