fix cos
This commit is contained in:
26
backend/.env.json
Normal file
26
backend/.env.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
return await response_base.success(data=result)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
file_size: int
|
||||
|
||||
@@ -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()
|
||||
@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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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__':
|
||||
|
||||
102
backend/middleware/cos_client.py
Normal file
102
backend/middleware/cos_client.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user