diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3099daf Binary files /dev/null and b/.DS_Store differ diff --git a/backend/.env.json b/backend/.env.json new file mode 100644 index 0000000..f37bfe6 --- /dev/null +++ b/backend/.env.json @@ -0,0 +1,26 @@ +{ + "ENVIRONMENT":"pro", + "SERVER_HOST": "0.0.0.0", + "SERVER_PORT": "8080", + "DATABASE_HOST": "10.16.108.51", + "DATABASE_PORT": "3306", + "DATABASE_USER": "root", + "DATABASE_PASSWORD": "Im_614000", + "DATABASE_DB_NAME": "app", + "REDIS_HOST": "127.0.0.1", + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "", + "REDIS_DATABASE": "0", + "TOKEN_SECRET_KEY": "1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk", + "QWEN_API_KEY": "sk-901901c68d6e44359ed31e9c59f6c8f9", + "QWEN_VISION_MODEL": "qwen-vl-plus", + "QWEN_VISION_EMBEDDING_MODEL": "multimodal-embedding-v1", + "TENCENT_CLOUD_APP_ID": "1251798270", + "TENCENT_CLOUD_SECRET_ID": "AKIDZ3CIFMVcadaRCrQr9HfLxRdlhYG0b2MX", + "TENCENT_CLOUD_SECRET_KEY": "51oZRMWirvsgLqCORK6kFdOPMAylakqw", + "TENCENT_CLOUD_VOICE_MODEL_TYPE": "501009", + "YOUDAO_APP_ID": "638c41df17d15cd6", + "YOUDAO_APP_SECRET": "VFicuogEnlQWaPPf4maGPnO1IztVzxVh", + "WX_APPID": "wxd6917f77eb2723fb", + "WX_SECRET": "5d39e4d49d46b07b86c7c1dbe0eb41f4" +} \ No newline at end of file diff --git a/backend/app/admin/api/v1/file.py b/backend/app/admin/api/v1/file.py index 383125e..2772ddf 100755 --- a/backend/app/admin/api/v1/file.py +++ b/backend/app/admin/api/v1/file.py @@ -17,6 +17,16 @@ async def upload_file( return response_base.success(data=result) +# @router.post("/upload_image", summary="上传图片", dependencies=[DependsJwtAuth]) +@router.post("/upload_image", summary="上传图片") +async def upload_image( + file: UploadFile = File(...), +) -> ResponseSchemaModel[FileUploadResponse]: + """上传图片,仅支持图片类型并检查重复""" + result = await file_service.upload_image(file) + return response_base.success(data=result) + + @router.get("/{file_id}", summary="下载文件", dependencies=[DependsJwtAuth]) # @router.get("/{file_id}", summary="下载文件") async def download_file(file_id: int) -> Response: @@ -38,4 +48,4 @@ async def delete_file(file_id: int) -> ResponseSchemaModel[bool]: result = await file_service.delete_file(file_id) if not result: return await response_base.fail(message="文件不存在或删除失败") - return await response_base.success(data=result) \ No newline at end of file + return await response_base.success(data=result) diff --git a/backend/app/admin/model/file.py b/backend/app/admin/model/file.py index 68cca54..00f51f5 100755 --- a/backend/app/admin/model/file.py +++ b/backend/app/admin/model/file.py @@ -23,7 +23,8 @@ class File(Base): file_data: Mapped[Optional[bytes]] = mapped_column(MEDIUMBLOB, default=None, nullable=True) # 文件二进制数据(数据库存储时使用) storage_type: Mapped[str] = mapped_column(String(20), nullable=False, default='database') # 存储类型: database, local, s3 metadata_info: Mapped[Optional[dict]] = mapped_column(MySQLJSON, default=None, comment="元数据信息") + details: Mapped[Optional[dict]] = mapped_column(MySQLJSON, default=None, comment="文件详情") __table_args__ = ( Index('idx_file_name', file_name), - ) \ No newline at end of file + ) diff --git a/backend/app/admin/schema/file.py b/backend/app/admin/schema/file.py index 921b4a5..8c19793 100755 --- a/backend/app/admin/schema/file.py +++ b/backend/app/admin/schema/file.py @@ -33,8 +33,10 @@ class AddFileParam(FileSchemaBase): class UpdateFileParam(SchemaBase): """更新文件参数""" + file_hash: Optional[str] = None storage_path: Optional[str] = None metadata_info: Optional[FileMetadata] = None + details: Optional[dict] = None class FileInfoSchema(FileSchemaBase): @@ -50,4 +52,4 @@ class FileUploadResponse(SchemaBase): file_hash: str file_name: str content_type: Optional[str] = None - file_size: int \ No newline at end of file + file_size: int diff --git a/backend/app/admin/service/file_service.py b/backend/app/admin/service/file_service.py index 3fee03f..157ceef 100755 --- a/backend/app/admin/service/file_service.py +++ b/backend/app/admin/service/file_service.py @@ -1,6 +1,8 @@ import base64 import io import imghdr +import json +import hashlib from datetime import datetime from typing import Optional, Dict, Any @@ -10,8 +12,9 @@ from PIL import Image as PILImage, ExifTags from backend.app.admin.crud.file_crud import file_dao from backend.app.admin.model.file import File from backend.app.admin.schema.file import AddFileParam, FileUploadResponse, UpdateFileParam, FileMetadata -from backend.app.ai.schema.image import ColorMode, ImageMetadata, ImageFormat -from backend.app.admin.service.file_storage import get_storage_provider, calculate_file_hash +from backend.app.ai.schema.image import ColorMode, ImageMetadata, ImageFormat, UpdateImageParam +from backend.app.ai.crud.image_curd import image_dao +from backend.middleware.cos_client import CosClient from backend.common.exception import errors from backend.core.conf import settings from backend.database.db import async_db_session @@ -57,7 +60,7 @@ class FileService: storage_type = settings.DEFAULT_STORAGE_TYPE # 计算文件哈希 - file_hash = calculate_file_hash(content) + file_hash = hashlib.sha256(content).hexdigest() # 检查文件是否已存在 async with async_db_session() as db: @@ -71,9 +74,6 @@ class FileService: file_size=existing_file.file_size ) - # 获取存储提供者 - storage_provider = get_storage_provider(storage_type) - # 创建文件记录 async with async_db_session.begin() as db: # 创建文件元数据 @@ -86,27 +86,6 @@ class FileService: "extra": metadata } - # 验证图片文件类型 - if file.filename: - FileService.validate_image_file(file.filename) - - # 如果是图片文件,提取图片元数据 - if FileService.is_image_file(file.content_type or "", content, file.filename or ""): - try: - additional_info = { - "file_name": file.filename, - "content_type": file.content_type, - "file_size": len(content), - } - image_metadata = file_service.extract_image_metadata(content, additional_info) - file_metadata_dict["image_info"] = image_metadata.dict() - except Exception as e: - # 如果提取图片元数据失败,记录错误但不中断上传 - file_metadata_dict["extra"] = { - **(metadata or {}), - "image_metadata_error": str(e) - } - file_metadata = FileMetadata(**file_metadata_dict) # 先创建文件记录,获取 file_id @@ -128,24 +107,55 @@ class FileService: storage_path = None file_data = None - if storage_type == "database": + if storage_type == "cos": + cos_client = CosClient() + key = cos_client.build_key(file_id) + avif_key = f"{file_id}_avif" + pic_ops = { + "is_pic_info": 1, + "rules": [{ + "fileid": avif_key, + "rule": "imageMogr2/format/avif" + }] + } + resp = cos_client.upload_image(key, content, pic_ops) + + data = None + if isinstance(resp, (list, tuple)) and len(resp) >= 2: + data = resp[1] + elif isinstance(resp, dict): + data = resp + else: + data = {} + + original_info = data.get("OriginalInfo", {}) or {} + process_results = data.get("ProcessResults", {}) or {} + image_info = original_info.pop("ImageInfo", None) + obj = process_results.get("Object", {}) or {} + avif_key = avif_key + + meta = FileMetadata( + file_name=file.filename, + content_type=file.content_type, + file_size=len(content), + extra={"cos_image_info": image_info} if image_info else metadata, + ) + + update_params = UpdateFileParam( + storage_path=avif_key, + metadata_info=meta, + details={ + "key": key, + "original_info": original_info, + "process_results": process_results, + } + ) + await file_dao.update(db, file_id, update_params) + db_file.storage_path = avif_key + else: # 数据库存储,将文件数据保存到数据库 db_file.file_data = content await db.flush() # 确保将file_data保存到数据库 - else: - # 其他存储方式(包括COS),使用真实的文件ID保存文件 - storage_path = await storage_provider.save( - file_id=file_id, - content=content, - file_name=file.filename, - ) - - # 更新数据库中的存储路径 - update_params = UpdateFileParam( - storage_path=storage_path - ) - await file_dao.update(db, file_id, update_params) - db_file.storage_path = storage_path return FileUploadResponse( id=str(db_file.id), @@ -168,7 +178,7 @@ class FileService: storage_type = settings.DEFAULT_STORAGE_TYPE # 计算文件哈希 - file_hash = calculate_file_hash(content) + file_hash = hashlib.sha256(content).hexdigest() # 检查文件是否已存在 async with async_db_session() as db: @@ -182,9 +192,6 @@ class FileService: file_size=existing_file.file_size ) - # 获取存储提供者 - storage_provider = get_storage_provider(storage_type) - # 创建文件记录 async with async_db_session.begin() as db: # 创建文件元数据 @@ -196,39 +203,16 @@ class FileService: "updated_at": datetime.now(), "extra": metadata } - - # 验证图片文件类型 - file_name = getattr(file, 'filename', 'unnamed') - if file_name: - FileService.validate_image_file(file_name) - # 如果是图片文件,提取图片元数据 - if FileService.is_image_file(content_type or "", content, file_name or ""): - try: - additional_info = { - "file_name": file_name, - "content_type": content_type, - "file_size": len(content), - } - image_metadata = file_service.extract_image_metadata(content, additional_info) - file_metadata_dict["image_info"] = image_metadata.dict() - except Exception as e: - # 如果提取图片元数据失败,记录错误但不中断上传 - file_metadata_dict["extra"] = { - **(metadata or {}), - "image_metadata_error": str(e) - } - + file_name = getattr(file, 'filename', 'unnamed') file_metadata = FileMetadata(**file_metadata_dict) - - # 先创建文件记录,获取 file_id file_params = AddFileParam( file_hash=file_hash, file_name=file_name or "unnamed", content_type=content_type, file_size=len(content), storage_type=storage_type, - storage_path=None, # 先设置为None,后面再更新 + storage_path=None, metadata_info=file_metadata ) @@ -241,23 +225,51 @@ class FileService: file_data = None if storage_type == "database": - # 数据库存储,将文件数据保存到数据库 db_file.file_data = content - await db.flush() # 确保将file_data保存到数据库 + await db.flush() else: - # 其他存储方式(包括COS),使用真实的文件ID保存文件 - storage_path = await storage_provider.save( - file_id=file_id, - content=content, - file_name=file_name or "unnamed" - ) - - # 更新数据库中的存储路径 + cos_client = CosClient() + key = cos_client.build_key(file_id) + avif_key = f"{key}_avif" + pic_ops = { + "is_pic_info": 1, + "rules": [{ + "fileid": avif_key, + "rule": "imageMogr2/format/avif" + }] + } + resp = cos_client.upload_image(key, content, pic_ops) + + data = None + if isinstance(resp, (list, tuple)) and len(resp) >= 2: + data = resp[1] + elif isinstance(resp, dict): + data = resp + else: + data = {} + + original_info = data.get("OriginalInfo", {}) or {} + process_results = data.get("ProcessResults", {}) or {} + image_info = original_info.pop("ImageInfo", None) + obj = process_results.get("Object", {}) or {} + avif_key = obj.get("Key") or avif_key + update_params = UpdateFileParam( - storage_path=storage_path + storage_path=avif_key, + metadata_info=FileMetadata( + file_name=file_name or "unnamed", + content_type=content_type, + file_size=len(content), + extra={"cos_image_info": image_info} if image_info else metadata, + ), + details={ + "key": key, + "original_info": original_info, + "process_results": process_results, + } ) await file_dao.update(db, file_id, update_params) - db_file.storage_path = storage_path + db_file.storage_path = avif_key return FileUploadResponse( id=str(db_file.id), @@ -267,6 +279,115 @@ class FileService: file_size=db_file.file_size ) + @staticmethod + async def upload_image( + file: UploadFile, + ) -> FileUploadResponse: + """上传图片:仅允许图片类型并进行重复性检查,不提取元信息""" + content = await file.read() + await file.seek(0) + + if not content: + raise errors.RequestError(msg="文件内容为空") + + # 严格判断是否为图片类型 + content_type = file.content_type or "" + is_image_by_type = content_type.startswith("image/") + is_image_by_content = imghdr.what(None, h=content) is not None + if not (is_image_by_type or is_image_by_content): + raise errors.ForbiddenError(msg="仅支持图片文件上传") + + storage_type = settings.DEFAULT_STORAGE_TYPE + file_hash = hashlib.sha256(content).hexdigest() + + # 检查文件是否重复 + async with async_db_session() as db: + existing_file = await file_dao.get_by_hash(db, file_hash) + if existing_file: + return FileUploadResponse( + id=str(existing_file.id), + file_hash=existing_file.file_hash, + file_name=existing_file.file_name, + content_type=existing_file.content_type, + file_size=existing_file.file_size, + ) + + # 创建文件记录(不提取图片元信息) + async with async_db_session.begin() as db: + meta = FileMetadata( + file_name=file.filename, + content_type=file.content_type, + file_size=len(content), + extra=None, + ) + + file_params = AddFileParam( + file_hash=file_hash, + file_name=file.filename or "unnamed", + content_type=file.content_type, + file_size=len(content), + storage_type=storage_type, + storage_path=None, + metadata_info=meta, + ) + + db_file = await file_dao.create(db, file_params) + file_id = db_file.id + + if storage_type == "database": + db_file.file_data = content + await db.flush() + else: + key = cos_client.build_key(file_id) + avif_key = f"{key}_avif" + pic_ops = { + "is_pic_info": 1, + "rules": [{ + "fileid": avif_key, + "rule": "imageMogr2/format/avif" + }] + } + resp = cos_client.upload_image(key, content, pic_ops) + + data = None + if isinstance(resp, (list, tuple)) and len(resp) >= 2: + data = resp[1] + elif isinstance(resp, dict): + data = resp + else: + data = {} + + original_info = data.get("OriginalInfo", {}) or {} + process_results = data.get("ProcessResults", {}) or {} + image_info = original_info.pop("ImageInfo", None) + obj = process_results.get("Object", {}) or {} + avif_key = obj.get("Key") or avif_key + + update_params = UpdateFileParam( + storage_path=avif_key, + metadata_info=FileMetadata( + file_name=file.filename, + content_type=file.content_type, + file_size=len(content), + extra={"cos_image_info": image_info} if image_info else None, + ), + details={ + "key": key, + "original_info": original_info, + "process_results": process_results, + } + ) + await file_dao.update(db, file_id, update_params) + db_file.storage_path = avif_key + + return FileUploadResponse( + id=str(db_file.id), + file_hash=db_file.file_hash, + file_name=db_file.file_name, + content_type=db_file.content_type, + file_size=db_file.file_size, + ) + @staticmethod async def get_file(file_id: int) -> Optional[File]: """获取文件信息""" @@ -282,14 +403,14 @@ class FileService: raise errors.NotFoundError(msg="文件不存在") content = b"" - storage_provider = get_storage_provider(db_file.storage_type) if db_file.storage_type == "database": # 从数据库获取文件数据 content = db_file.file_data or b"" else: - # 从存储中读取文件 - content = await storage_provider.read(file_id, db_file.storage_path or "") + cos_client = CosClient() + key = db_file.storage_path or cos_client.build_key(file_id) + content = cos_client.download_object(key) return content, db_file.file_name, db_file.content_type or "application/octet-stream" @@ -301,10 +422,13 @@ class FileService: if not db_file: return False - # 删除存储中的文件 if db_file.storage_type != "database": - storage_provider = get_storage_provider(db_file.storage_type) - await storage_provider.delete(file_id, db_file.storage_path or "") + cos_client = CosClient() + key = db_file.storage_path or cos_client.build_key(file_id) + try: + cos_client.client.delete_object(Bucket=cos_client.bucket, Key=key) + except Exception: + pass # 删除数据库记录 result = await file_dao.delete(db, file_id) @@ -417,4 +541,145 @@ class FileService: error=f"Metadata extraction failed: {str(e)}" ) -file_service = FileService() \ No newline at end of file + @staticmethod + async def generate_thumbnail(image_id: int, file_id: int) -> None: + try: + db_file = await FileService.get_file(file_id) + if not db_file: + return + + if db_file.storage_type == "cos": + cos_client = CosClient() + src_key = db_file.storage_path or cos_client.build_key(file_id) + + async with async_db_session.begin() as db: + meta_init = FileMetadata( + file_name=f"thumbnail_{db_file.file_name}", + content_type=db_file.content_type, + file_size=0, + extra=None, + ) + t_params = AddFileParam( + file_hash=hashlib.sha256(f"cos:thumbnail_pending:{image_id}:{file_id}".encode()).hexdigest(), + file_name=f"thumbnail_{db_file.file_name}", + content_type=db_file.content_type, + file_size=0, + storage_type="cos", + storage_path=None, + metadata_info=meta_init, + ) + t_file = await file_dao.create(db, t_params) + await db.flush() + + dest_key = f"{t_file.id}_thumbnail" + # 10% 缩放 + pic_ops = { + "is_pic_info": 1, + "rules": [{ + "fileid": dest_key, + "rule": "/thumbnail/!10p" + }] + } + resp = cos_client.process_image(src_key, pic_ops) + + data = None + if isinstance(resp, (list, tuple)) and len(resp) >= 2: + data = resp[1] + elif isinstance(resp, dict): + data = resp + else: + data = {} + + process_results = data.get("ProcessResults", {}) or {} + obj = process_results.get("Object", {}) or {} + final_key = obj.get("Key") or dest_key + fmt = obj.get("Format") + size_str = obj.get("Size") + try: + size_val = int(size_str) if isinstance(size_str, str) else (size_str or 0) + except: + size_val = 0 + content_type = f"image/{fmt.lower()}" if isinstance(fmt, str) else (db_file.content_type or "image/avif") + + meta = FileMetadata( + file_name=f"thumbnail_{db_file.file_name}", + content_type=content_type, + file_size=size_val, + extra=None, + ) + + async with async_db_session.begin() as db: + update_params = UpdateFileParam( + file_hash=hashlib.sha256(f"cos:{final_key}".encode()).hexdigest(), + storage_path=final_key, + metadata_info=meta, + details={ + "key": src_key, + "process_results": process_results, + } + ) + await file_dao.update(db, t_file.id, update_params) + await image_dao.update(db, image_id, UpdateImageParam(thumbnail_id=t_file.id)) + else: + file_content, file_name, content_type = await FileService.download_file(file_id) + thumbnail_content = await FileService._create_thumbnail(file_content) + if not thumbnail_content: + thumbnail_content = file_content + + meta = FileMetadata( + file_name=f"thumbnail_{file_name}", + content_type=content_type, + file_size=len(thumbnail_content), + extra=None, + ) + + async with async_db_session.begin() as db: + t_params = AddFileParam( + file_hash=hashlib.sha256(thumbnail_content).hexdigest(), + file_name=f"thumbnail_{file_name}", + content_type=content_type, + file_size=len(thumbnail_content), + storage_type="database", + storage_path=None, + metadata_info=meta, + ) + t_file = await file_dao.create(db, t_params) + t_file.file_data = thumbnail_content + await db.flush() + await image_dao.update(db, image_id, UpdateImageParam(thumbnail_id=t_file.id)) + except Exception: + pass + + @staticmethod + async def _create_thumbnail(image_bytes: bytes, size: tuple = (100, 100)) -> bytes: + try: + if not image_bytes: + return None + with PILImage.open(io.BytesIO(image_bytes)) as img: + if img.mode in ("RGBA", "LA", "P"): + background = PILImage.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 + width, height = img.size + if width > height: + left = (width - height) // 2 + right = left + height + top = 0 + bottom = height + else: + left = 0 + right = width + top = (height - width) // 2 + bottom = top + width + img = img.crop((left, top, right, bottom)) + img = img.resize(size, PILImage.Resampling.LANCZOS) + thumbnail_buffer = io.BytesIO() + img.save(thumbnail_buffer, format=img.format or "JPEG") + thumbnail_buffer.seek(0) + return thumbnail_buffer.read() + except Exception: + return None + +file_service = FileService() diff --git a/backend/app/admin/service/file_storage.py b/backend/app/admin/service/file_storage.py deleted file mode 100755 index 6a510b4..0000000 --- a/backend/app/admin/service/file_storage.py +++ /dev/null @@ -1,219 +0,0 @@ -import hashlib -import json -import os -from abc import ABC, abstractmethod -import aiofiles - -from qcloud_cos import CosConfig -from qcloud_cos import CosS3Client - -from backend.core.conf import settings - - -class StorageProvider(ABC): - """存储提供者抽象基类""" - - @abstractmethod - async def save(self, file_id: int, content: bytes, file_name: str) -> str: - """保存文件""" - pass - - @abstractmethod - async def read(self, file_id: int, storage_path: str) -> bytes: - """读取文件""" - pass - - @abstractmethod - async def delete(self, file_id: int, storage_path: str) -> bool: - """删除文件""" - pass - - @abstractmethod - async def compress_image(self, file_id: int) -> str: - """压缩图片并返回压缩后路径或键""" - pass - - @abstractmethod - async def object_url(self, cos_key: str) -> str: - """根据存储键返回可访问的URL或路径""" - pass - - -class DatabaseStorage(StorageProvider): - """数据库存储提供者""" - - async def save(self, file_id: int, content: bytes, file_name: str) -> str: - """数据库存储不需要实际保存文件,直接返回空字符串""" - return "" - - async def read(self, file_id: int, storage_path: str) -> bytes: - """数据库存储不需要读取文件""" - return b"" - - async def delete(self, file_id: int, storage_path: str) -> bool: - """数据库存储不需要删除文件""" - return True - - async def compress_image(self, file_id: int) -> str: - """数据库存储不涉及文件压缩,返回空字符串""" - return "" - - async def object_url(self, cos_key: str) -> str: - """数据库存储不提供URL,返回空字符串""" - return "" - - -class LocalStorage(StorageProvider): - """本地文件系统存储提供者""" - - def __init__(self, base_path: str = settings.STORAGE_PATH): - self.base_path = base_path - - def _get_path(self, file_id: int, file_name: str) -> str: - """构建文件路径""" - # 使用文件ID作为目录名,避免单个目录下文件过多 - dir_name = str(file_id // 1000) - file_dir = os.path.join(self.base_path, dir_name) - os.makedirs(file_dir, exist_ok=True) - return os.path.join(file_dir, f"{file_id}_{file_name}") - - async def save(self, file_id: int, content: bytes, file_name: str) -> str: - """保存文件到本地""" - path = self._get_path(file_id, file_name) - async with aiofiles.open(path, 'wb') as f: - await f.write(content) - return path - - async def read(self, file_id: int, storage_path: str) -> bytes: - """从本地读取文件""" - async with aiofiles.open(storage_path, 'rb') as f: - return await f.read() - - async def delete(self, file_id: int, storage_path: str) -> bool: - """从本地删除文件""" - try: - os.remove(storage_path) - return True - except: - return False - - async def compress_image(self, file_id: int) -> str: - """本地压缩占位实现,返回预期压缩文件路径""" - dir_name = str(file_id // 1000) - file_dir = os.path.join(self.base_path, dir_name) - os.makedirs(file_dir, exist_ok=True) - return os.path.join(file_dir, f"{file_id}.heif") - - async def object_url(self, cos_key: str) -> str: - """本地存储返回本地路径作为访问地址""" - return cos_key - - -class CosStorage(StorageProvider): - """腾讯云COS存储提供者""" - - def __init__(self): - """初始化COS客户端""" - import logging - # Reduce verbosity of COS client logging - logging.getLogger('qcloud_cos').setLevel(logging.WARNING) - - secret_id = settings.COS_SECRET_ID - secret_key = settings.COS_SECRET_KEY - region = settings.COS_REGION - bucket = settings.COS_BUCKET - config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) - self.basic_dir = settings.COS_BASIC_DIR - self.client = CosS3Client(config) - self.bucket = bucket - - def _get_key(self, file_id: int) -> str: - """构建COS对象键""" - return f"{self.basic_dir}/{file_id}" - - async def save(self, file_id: int, content: bytes, file_name: str) -> str: - """保存文件到COS""" - key = self._get_key(file_id) - # res = compress_image(2106103703009361920) - # 上传文件到COS - pic_ops = { - "is_pic_info": 1, - "rules": [ - { - "bucket": self.bucket, - "fileid": f"{key}", - "rule": "imageMogr2/format/avif", - } - ], - } - - try: - response = self.client.put_object( - Bucket=self.bucket, - Body=content, - Key=key, - StorageClass='STANDARD', - EnableMD5=False, - PicOperations=json.dumps(pic_ops) - ) - except Exception as e: - print(f"详细错误: {e}") - print(f"错误类型: {type(e)}") - - # 返回存储路径(即对象键) - return key - - async def read(self, file_id: int, storage_path: str) -> bytes: - """从COS读取文件""" - # 下载文件 - response = self.client.get_object( - Bucket=self.bucket, - Key=self._get_key(file_id) - ) - - # 返回文件内容 - return response['Body'].get_raw_stream().read() - - async def delete(self, file_id: int, storage_path: str) -> bool: - """从COS删除文件""" - try: - self.client.delete_object( - Bucket=self.bucket, - Key=storage_path - ) - return True - except Exception: - return False - - async def compress_image(self, file_id: int) -> str: - key = self._get_key(file_id) - res_path=f"{key}.heif" - response = self.client.ci_download_compress_image( - Bucket=self.bucket, - Key=key, - DestImagePath=res_path, - CompressType='avif' - ) - return res_path - - async def object_url(self, cos_key: str) -> str: - url = self.client.get_object_url( - Bucket=self.bucket, - Key=cos_key, - ) - return url - - -def get_storage_provider(provider_type: str) -> StorageProvider: - """根据配置获取存储提供者""" - if provider_type == "local": - return LocalStorage() - elif provider_type == "cos": - return CosStorage() - else: # 默认使用数据库存储 - return DatabaseStorage() - - -def calculate_file_hash(content: bytes) -> str: - """计算文件的SHA256哈希值""" - return hashlib.sha256(content).hexdigest() diff --git a/backend/app/ai/service/image_service.py b/backend/app/ai/service/image_service.py index e8cce73..6801bb7 100755 --- a/backend/app/ai/service/image_service.py +++ b/backend/app/ai/service/image_service.py @@ -19,6 +19,9 @@ from backend.app.ai.schema.image import ImageFormat, ImageMetadata, ColorMode, I ProcessImageRequest from backend.app.admin.schema.qwen import QwenRecognizeImageParams from backend.app.admin.service.file_service import file_service +from backend.middleware.cos_client import CosClient +from backend.app.admin.crud.file_crud import file_dao +from backend.app.admin.schema.file import AddFileParam, FileMetadata from backend.app.ai.service.rate_limit_service import rate_limit_service from backend.common.enums import FileType from backend.common.exception import errors @@ -171,109 +174,11 @@ class ImageService: @staticmethod async def generate_thumbnail(image_id: int, file_id: int) -> None: - """生成缩略图并更新image记录""" - try: - # 下载原始图片 - file_content, file_name, content_type = await file_service.download_file(file_id) - - # 生成缩略图 - thumbnail_content = await ImageService._create_thumbnail(file_content) - - # 如果缩略图生成失败,使用原始图片作为缩略图 - if not thumbnail_content: - thumbnail_content = file_content - - # 上传缩略图到文件服务 - # 创建一个虚拟的文件对象用于上传 - class MockUploadFile: - def __init__(self, filename, content): - self.filename = filename - self._file = io.BytesIO(content) - self.size = len(content) - - async def read(self): - """读取文件内容""" - self._file.seek(0) - return self._file.read() - - async def seek(self, position): - """重置文件指针""" - self._file.seek(position) - - thumbnail_file = MockUploadFile( - filename=f"thumbnail_{file_name}", - content=thumbnail_content - ) - - # 上传缩略图,使用新的方法并显式传递content_type - thumbnail_response = await file_service.upload_file_with_content_type( - thumbnail_file, - content_type=content_type - ) - thumbnail_file_id = int(thumbnail_response.id) - - # 更新image记录的thumbnail_id字段 - async with async_db_session.begin() as db: - await image_dao.update( - db, - image_id, - UpdateImageParam(thumbnail_id=thumbnail_file_id) - ) - - except Exception as e: - logger.error(f"生成缩略图失败: {str(e)}") - # 不抛出异常,避免影响主流程 + await file_service.generate_thumbnail(image_id, file_id) @staticmethod async def _create_thumbnail(image_bytes: bytes, size: tuple = (100, 100)) -> bytes: - """创建缩略图""" - try: - # 检查输入是否为空 - if not image_bytes: - return None - - # 打开原始图片 - with PILImage.open(io.BytesIO(image_bytes)) as img: - # 转换为RGB模式(如果需要) - if img.mode in ("RGBA", "LA", "P"): - # 创建白色背景 - background = PILImage.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 - - # 居中裁剪图片为正方形 - width, height = img.size - if width > height: - # 宽度大于高度,裁剪水平中部 - left = (width - height) // 2 - right = left + height - top = 0 - bottom = height - else: - # 高度大于宽度,裁剪垂直中部 - left = 0 - right = width - top = (height - width) // 2 - bottom = top + width - - # 执行裁剪 - img = img.crop((left, top, right, bottom)) - - # 调整图片尺寸为指定大小 - img = img.resize(size, PILImage.Resampling.LANCZOS) - - # 保存缩略图到字节流 - thumbnail_buffer = io.BytesIO() - img.save(thumbnail_buffer, format=img.format or "JPEG") - thumbnail_buffer.seek(0) - - return thumbnail_buffer.read() - except Exception as e: - logger.error(f"创建缩略图失败: {str(e)}") - # 如果失败,返回None - return None + return await file_service._create_thumbnail(image_bytes, size) @staticmethod async def process_image_from_file_async( @@ -333,7 +238,7 @@ class ImageService: image_id = new_image.id # 生成缩略图 - background_tasks.add_task(ImageService.generate_thumbnail, image_id, file_id) + background_tasks.add_task(file_service.generate_thumbnail, image_id, file_id) # embedding # embed_params = QwenEmbedImageParams( diff --git a/backend/main.py b/backend/main.py index dd1cf62..1265dce 100755 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,7 @@ from pathlib import Path import uvicorn - +from backend.middleware.cos_client import CosClient from backend.core.registrar import register_app from backend.core.conf import settings @@ -11,7 +11,7 @@ app = register_app() @app.get("/") def read_root(): - return {"Hello": "World"} + return {"Hello": f"World"} if __name__ == '__main__': diff --git a/backend/middleware/cos_client.py b/backend/middleware/cos_client.py new file mode 100644 index 0000000..9afcf8b --- /dev/null +++ b/backend/middleware/cos_client.py @@ -0,0 +1,102 @@ +from qcloud_cos import CosConfig +from qcloud_cos import CosS3Client +import json + +from backend.core.conf import settings +from backend.common.exception import errors +from backend.common.log import log as logger + + +class CosClient: + def __init__(self, + secret_id: str | None = None, + secret_key: str | None = None, + region: str | None = None, + bucket: str | None = None, + basic_dir: str | None = None): + self.secret_id = secret_id or settings.COS_SECRET_ID + self.secret_key = secret_key or settings.COS_SECRET_KEY + self.region = region or settings.COS_REGION + self.bucket = bucket or settings.COS_BUCKET + self.basic_dir = basic_dir or settings.COS_BASIC_DIR + config = CosConfig(Region=self.region, SecretId=self.secret_id, SecretKey=self.secret_key) + self.client = CosS3Client(config) + + def build_key(self, key: str | int) -> str: + return f"{self.basic_dir}/{key}" + + def upload_object(self, file_id: str, content: bytes, pic_operations: dict | None = None) -> dict: + try: + resp = self.client.put_object( + Bucket=self.bucket, + Body=content, + Key=file_id, + StorageClass='STANDARD', + EnableMD5=False, + PicOperations=json.dumps(pic_operations) + ) + return resp + except Exception as e: + logger.error(f"cos upload_object failed: {e}") + raise errors.ServerError(msg="COS upload failed") + + def upload_image(self, key: str, content: bytes, pic_operations: dict | None = None) -> dict: + try: + resp = self.client.ci_put_object( + Bucket=self.bucket, + Body=content, + Key=key, + PicOperations=json.dumps(pic_operations) + ) + print(json.dumps(resp)) + return resp + except Exception as e: + logger.error(f"cos upload_image failed: {e}") + raise errors.ServerError(msg="COS upload image failed") + + + def download_object(self, key: str) -> bytes: + try: + resp = self.client.get_object(Bucket=self.bucket, Key=key) + return resp['Body'].get_raw_stream().read() + except Exception as e: + logger.error(f"cos download_object failed: {e}") + raise errors.ServerError(msg="COS download failed") + + def copy_object(self, src_file_id: str, dest_file_id: str) -> dict: + try: + src_key = self.build_key(src_file_id) + dest_key = self.build_key(dest_file_id) + return self.client.copy_object( + Bucket=self.bucket, + Key=dest_key, + CopySource={ + 'Bucket': self.bucket, + 'Key': src_key, + 'Region': self.region + } + ) + except Exception as e: + logger.error(f"cos copy_object failed: {e}") + raise errors.ServerError(msg="COS copy failed") + + def object_url(self, key: str) -> str: + try: + return self.client.get_object_url(Bucket=self.bucket, Key=key) + except Exception as e: + logger.error(f"cos object_url failed: {e}") + raise errors.ServerError(msg="COS get url failed") + + def process_image(self, key: str, pic_operations: dict | str): + try: + ops = pic_operations if isinstance(pic_operations, str) else json.dumps(pic_operations) + response = self.client.ci_image_process( + Bucket=self.bucket, + Key=key, + PicOperations=ops + ) + print(json.dumps(response)) + return response + except Exception as e: + logger.error(f"cos process_image failed: {e}") + raise errors.ServerError(msg="COS image process failed")