добавил minio

This commit is contained in:
ilya_zahvatkin 2025-04-03 23:17:57 +07:00
parent 48e588bb82
commit 6aef5fb7ce
22 changed files with 190 additions and 127 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -101,4 +101,12 @@ UPLOAD_DIRECTORY=/app/uploads
## Лицензия
[MIT License](LICENSE)
[MIT License](LICENSE)
# Сначала получаем SSL-сертификат
./init-letsencrypt.sh ваш-домен.ru
# Затем запускаем сервисы
docker-compose -f docker-compose.prod.yml up -d

BIN
backend/.DS_Store vendored

Binary file not shown.

BIN
backend/app/.DS_Store vendored

Binary file not shown.

View File

@ -2,3 +2,9 @@ CDEK_LOGIN=q8AQtmLL7kPg6TuDo1eB2uqelJS4tHn2
CDEK_PASSWORD=RmAmgvSgSl1yirlz9QupbzOJVqhCxcP5
# CDEK_BASE_URL=https://api.cdek.ru/v2
CDEK_BASE_URL=https://api.edu.cdek.ru/v2
MINIO_ENDPOINT_URL = http://45.129.128.113:9000
MINIO_ACCESS_KEY = ZIK_DressedForSuccess
MINIO_SECRET_KEY = ZIK_DressedForSuccess_/////ZIK_DressedForSuccess_!
MINIO_BUCKET_NAME = dressedforsuccess
MINIO_USE_SSL = false

View File

@ -27,18 +27,17 @@ MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com")
MAIL_TLS = True
MAIL_SSL = False
# Настройки загрузки файлов
# Настройки загрузки файлов (старые, для информации)
# UPLOAD_DIRECTORY = os.getenv("UPLOAD_DIRECTORY", "uploads")
UPLOAD_DIRECTORY = "/"
ALLOWED_UPLOAD_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 МБ
# Создаем директорию для загрузок при запуске, если её нет
uploads_dir = Path(UPLOAD_DIRECTORY)
uploads_dir.mkdir(parents=True, exist_ok=True)
products_dir = uploads_dir / "products"
products_dir.mkdir(parents=True, exist_ok=True)
# Настройки MinIO/S3
MINIO_ENDPOINT_URL = os.getenv("MINIO_ENDPOINT_URL", "http://localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET_NAME = os.getenv("MINIO_BUCKET_NAME", "images")
MINIO_USE_SSL = os.getenv("MINIO_USE_SSL", "false").lower() == "true"
# Настройки корзины
CART_EXPIRATION_DAYS = 30 # Срок хранения корзины
@ -71,11 +70,18 @@ class Settings(BaseSettings):
"http://localhost:8080",
]
# Настройки для загрузки файлов
UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY
# Старые настройки для загрузки файлов (удалить или закомментировать)
# UPLOAD_DIRECTORY: str = UPLOAD_DIRECTORY
MAX_UPLOAD_SIZE: int = MAX_UPLOAD_SIZE
ALLOWED_UPLOAD_EXTENSIONS: list = list(ALLOWED_UPLOAD_EXTENSIONS)
# Настройки MinIO/S3
MINIO_ENDPOINT_URL: str = MINIO_ENDPOINT_URL
MINIO_ACCESS_KEY: str = MINIO_ACCESS_KEY
MINIO_SECRET_KEY: str = MINIO_SECRET_KEY
MINIO_BUCKET_NAME: str = MINIO_BUCKET_NAME
MINIO_USE_SSL: bool = MINIO_USE_SSL
# Настройки для платежных систем (пример)
PAYMENT_GATEWAY_API_KEY: str = os.getenv("PAYMENT_GATEWAY_API_KEY", "")
PAYMENT_GATEWAY_SECRET: str = os.getenv("PAYMENT_GATEWAY_SECRET", "")

View File

@ -40,13 +40,6 @@ async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
# Подключаем роутеры
app.include_router(router, prefix="/api")
# Создаем директорию для загрузок, если она не существует
uploads_dir = Path(settings.UPLOAD_DIRECTORY)
uploads_dir.mkdir(parents=True, exist_ok=True)
# Монтируем статические файлы
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIRECTORY), name="uploads")
# Корневой маршрут
@app.get("/")
async def root():

View File

@ -3,10 +3,10 @@ from fastapi import HTTPException, status, UploadFile
from typing import List, Dict, Any, Optional
import os
import uuid
import shutil
from pathlib import Path
import logging
import traceback
import boto3
from botocore.exceptions import ClientError
from app.config import settings
from app.repositories import catalog_repo, review_repo
@ -393,7 +393,7 @@ def upload_product_image(
alt_text: str = ""
) -> dict:
"""
Загружает изображение для продукта и создает запись в базе данных.
Загружает изображение для продукта в MinIO и создает запись в базе данных.
Args:
db: Сессия базы данных
@ -403,13 +403,15 @@ def upload_product_image(
alt_text: Альтернативный текст для изображения
Returns:
dict: Словарь с данными созданного изображения
dict: Словарь с данными созданного изображения (image_url содержит ключ объекта)
Raises:
HTTPException: В случае ошибки при загрузке или сохранении изображения
"""
s3_client = None
secure_filename = ""
try:
logging.info(f"Попытка загрузки изображения для продукта с id={product_id}")
logging.info(f"Попытка загрузки изображения для продукта с id={product_id} в MinIO")
if file is None:
error_msg = "Файл не предоставлен"
@ -421,60 +423,63 @@ def upload_product_image(
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
logging.info(f"Получен файл: {file.filename}, размер: {file.size if hasattr(file, 'size') else 'unknown'}")
logging.info(f"Получен файл: {file.filename}, content_type: {file.content_type}")
# Исправляем импорт модели Product для ORM запроса
from app.models.catalog_models import Product as ProductModel, ProductImage as ProductImageModel
# Проверяем, что продукт существует
product = db.query(ProductModel).filter(ProductModel.id == product_id).first()
if not product:
error_msg = f"Продукт с ID {product_id} не найден"
logging.error(error_msg)
raise HTTPException(status_code=404, detail=error_msg)
# Создаем директорию для изображений, если ее нет
os.makedirs(settings.UPLOAD_DIRECTORY, exist_ok=True)
logging.info(f"Директория для загрузки: {settings.UPLOAD_DIRECTORY}")
# Получаем расширение файла
file_ext = os.path.splitext(file.filename)[1].lower() if file.filename else ""
logging.info(f"Расширение файла: {file_ext}")
# Проверяем допустимость расширения (удаляем точку перед сравнением)
if file_ext.lstrip('.') not in settings.ALLOWED_UPLOAD_EXTENSIONS:
error_msg = f"Расширение файла {file_ext} не разрешено. Разрешенные расширения: {', '.join(settings.ALLOWED_UPLOAD_EXTENSIONS)}"
error_msg = f"Расширение файла {file_ext} не разрешено."
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
# Генерируем безопасное имя файла
secure_filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(settings.UPLOAD_DIRECTORY, secure_filename)
logging.info(f"Генерация имени файла: {secure_filename}, полный путь: {file_path}")
logging.info(f"Генерация ключа объекта MinIO: {secure_filename}")
# Сохраняем файл на диск
try:
with open(file_path, "wb") as f:
content = file.file.read()
if not content:
error_msg = "Загруженный файл пуст"
logging.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
f.write(content)
logging.info(f"Файл успешно сохранен на диск, размер содержимого: {len(content)} байт")
s3_client = boto3.client(
's3',
endpoint_url=settings.MINIO_ENDPOINT_URL,
aws_access_key_id=settings.MINIO_ACCESS_KEY,
aws_secret_access_key=settings.MINIO_SECRET_KEY,
use_ssl=settings.MINIO_USE_SSL,
config=boto3.session.Config(signature_version='s3v4')
)
logging.info(f"Клиент S3 инициализирован для эндпоинта: {settings.MINIO_ENDPOINT_URL}")
except Exception as e:
error_msg = f"Ошибка при сохранении файла: {str(e)}"
error_msg = f"Ошибка инициализации S3 клиента: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=error_msg)
# Получаем URL для файла
file_url = f"/uploads/{secure_filename}"
logging.info(f"URL файла: {file_url}")
raise HTTPException(status_code=500, detail="Ошибка конфигурации хранилища")
try:
s3_client.upload_fileobj(
file.file,
settings.MINIO_BUCKET_NAME,
secure_filename,
ExtraArgs={'ContentType': file.content_type}
)
logging.info(f"Файл успешно загружен в MinIO бакет '{settings.MINIO_BUCKET_NAME}' с ключом '{secure_filename}'")
except ClientError as e:
error_msg = f"Ошибка при загрузке файла в MinIO: {e.response['Error']['Message']}"
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail="Ошибка при сохранении файла в хранилище")
except Exception as e:
error_msg = f"Неизвестная ошибка при загрузке файла в MinIO: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail="Ошибка при сохранении файла в хранилище")
image_object_key = secure_filename
logging.info(f"Ключ объекта для сохранения в БД: {image_object_key}")
# Если это основное изображение, обновляем остальные изображения продукта
if is_primary:
logging.info("Обновление флагов для других изображений (is_primary=False)")
db.query(ProductImageModel).filter(
@ -482,11 +487,10 @@ def upload_product_image(
ProductImageModel.is_primary == True
).update({"is_primary": False})
# Создаем запись в базе данных
logging.info("Создание записи изображения в базе данных")
new_image = ProductImageModel(
product_id=product_id,
image_url=file_url,
image_url=image_object_key,
alt_text=alt_text,
is_primary=is_primary
)
@ -497,21 +501,24 @@ def upload_product_image(
logging.info("Запись успешно добавлена в базу данных")
db.refresh(new_image)
except Exception as e:
db.rollback()
error_msg = f"Ошибка при сохранении записи в базу данных: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
# Удаляем загруженный файл при ошибке
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за ошибки БД.")
try:
os.remove(file_path)
logging.info(f"Файл {file_path} успешно удален из-за ошибки при создании записи в БД")
except:
logging.error(f"Не удалось удалить файл {file_path} после ошибки")
if s3_client and secure_filename:
s3_client.delete_object(
Bucket=settings.MINIO_BUCKET_NAME,
Key=secure_filename
)
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
except Exception as delete_err:
logging.error(f"Не удалось удалить объект '{secure_filename}' из MinIO после ошибки БД: {str(delete_err)}")
raise HTTPException(status_code=500, detail=error_msg)
raise HTTPException(status_code=500, detail="Ошибка сохранения данных изображения")
# Возвращаем данные изображения напрямую как словарь
# вместо использования Pydantic-моделей
result = {
"id": new_image.id,
"product_id": new_image.product_id,
@ -526,14 +533,22 @@ def upload_product_image(
return result
except HTTPException as http_error:
# Пробрасываем HTTP-исключения далее
raise http_error
except Exception as e:
# Логируем ошибки и преобразуем их в HTTP-исключения
error_msg = f"Неожиданная ошибка при загрузке изображения: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=error_msg)
if s3_client and secure_filename:
logging.warning(f"Пытаемся удалить объект '{secure_filename}' из MinIO из-за неожиданной ошибки.")
try:
s3_client.delete_object(
Bucket=settings.MINIO_BUCKET_NAME,
Key=secure_filename
)
logging.info(f"Объект '{secure_filename}' успешно удален из MinIO.")
except Exception as delete_err:
logging.error(f"Не удалось удалить объект '{secure_filename}' из MinIO после ошибки: {str(delete_err)}")
raise HTTPException(status_code=500, detail="Внутренняя ошибка сервера при загрузке файла")
def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]:
@ -596,33 +611,72 @@ def update_product_image(db: Session, image_id: int, image: ProductImageUpdate)
def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
# Добавляем импорт ORM-модели для корректной работы с БД
from app.models.catalog_models import ProductImage as ProductImageModel
# Получаем информацию об изображении перед удалением
image = catalog_repo.get_product_image(db, image_id)
if not image:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение не найдено"
)
image_key_to_delete = None # Сохраняем ключ для удаления из MinIO
s3_client = None
# Удаляем запись из БД
success = catalog_repo.delete_product_image(db, image_id)
# Удаляем файл с диска
if success:
try:
# Получаем путь к файлу из URL
file_path = Path(settings.UPLOAD_DIRECTORY) / image.image_url.lstrip("/uploads/")
if file_path.exists():
file_path.unlink()
except Exception:
# Если не удалось удалить файл, просто логируем ошибку
# В реальном приложении здесь должно быть логирование
pass
return {"success": success}
try:
# Получаем информацию об изображении перед удалением
db_image = catalog_repo.get_product_image(db, image_id)
if not db_image:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Изображение не найдено"
)
image_key_to_delete = db_image.image_url # Получаем ключ объекта MinIO из БД
logging.info(f"Получен ключ объекта для удаления из MinIO: {image_key_to_delete}")
# Удаляем запись из БД
success = catalog_repo.delete_product_image(db, image_id)
# Если запись из БД удалена успешно, удаляем объект из MinIO
if success and image_key_to_delete:
logging.info(f"Запись из БД удалена, попытка удаления объекта '{image_key_to_delete}' из MinIO.")
try:
# Инициализация клиента S3
s3_client = boto3.client(
's3',
endpoint_url=settings.MINIO_ENDPOINT_URL,
aws_access_key_id=settings.MINIO_ACCESS_KEY,
aws_secret_access_key=settings.MINIO_SECRET_KEY,
use_ssl=settings.MINIO_USE_SSL,
config=boto3.session.Config(signature_version='s3v4')
)
s3_client.delete_object(
Bucket=settings.MINIO_BUCKET_NAME,
Key=image_key_to_delete
)
logging.info(f"Объект '{image_key_to_delete}' успешно удален из MinIO бакета '{settings.MINIO_BUCKET_NAME}'.")
except ClientError as e:
# Если не удалось удалить объект из MinIO, логируем ошибку, но не прерываем процесс,
# так как запись из БД уже удалена. Возможно, потребуется ручная очистка.
logging.error(f"Ошибка при удалении объекта '{image_key_to_delete}' из MinIO: {e.response['Error']['Message']}")
logging.error(traceback.format_exc())
# Можно рассмотреть вариант возврата другого статуса или сообщения об ошибке
except Exception as e:
logging.error(f"Неизвестная ошибка при удалении объекта '{image_key_to_delete}' из MinIO: {str(e)}")
logging.error(traceback.format_exc())
elif not success:
logging.error(f"Не удалось удалить запись изображения с ID {image_id} из БД.")
elif not image_key_to_delete:
logging.warning(f"Ключ объекта MinIO не найден для изображения с ID {image_id}. Удаление из MinIO пропущено.")
return {"success": success}
except HTTPException as http_error:
# Если изображение не найдено, пробрасываем ошибку
raise http_error
except Exception as e:
# Общая ошибка
error_msg = f"Неожиданная ошибка при удалении изображения с ID {image_id}: {str(e)}"
logging.error(error_msg)
logging.error(traceback.format_exc())
# Важно: Не пытаемся откатить транзакцию здесь, если catalog_repo.delete_product_image уже сделал commit
# Если catalog_repo.delete_product_image вызывает исключение до commit, rollback произойдет там.
return {"success": False, "error": "Внутренняя ошибка сервера при удалении изображения"}
# Функции для работы с размерами

View File

@ -1,4 +1,3 @@
aiofiles==24.1.0
aiogram==3.17.0
aiohappyeyeballs==2.4.4
@ -106,3 +105,4 @@ python-multipart==0.0.6
email-validator==2.1.0
setuptools==75.8.0
cachetools
boto3

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

BIN
frontend/.DS_Store vendored

Binary file not shown.

View File

@ -221,60 +221,56 @@ export interface CollectionUpdate {
}
/**
* Нормализует изображение продукта, добавляя базовый URL при необходимости
* @param imageUrl URL изображения
* @returns Нормализованный URL
* Нормализует URL изображения, добавляя базовый URL MinIO при необходимости.
* @param imageUrl Ключ объекта MinIO или полный URL.
* @returns Полный URL изображения или плейсхолдер.
*/
export function normalizeProductImage(imageUrl: string | null | undefined): string {
const minioBaseUrl = process.env.NEXT_PUBLIC_MINIO_URL;
const placeholder = '/placeholder.jpg'; // Путь к вашему плейсхолдеру
if (!imageUrl) {
if (apiStatus.debugMode) {
// console.log('Изображение отсутствует, возвращаю placeholder');
// console.log('Ключ/URL изображения отсутствует, возвращаю плейсхолдер');
}
return '/placeholder.jpg';
return placeholder;
}
let result = '';
if (imageUrl.startsWith('http') || imageUrl.startsWith('data:')) {
// Если это уже полный URL (http, https, data URI, blob)
if (imageUrl.startsWith('http') || imageUrl.startsWith('data:') || imageUrl.startsWith('blob:')) {
if (apiStatus.debugMode) {
// console.log(`Изображение уже содержит полный URL: ${imageUrl}`);
// console.log(`Изображение уже содержит полный URL/Data URI/Blob: ${imageUrl}`);
}
return imageUrl;
}
// Если путь начинается с '/uploads', это путь к загруженному файлу
if (imageUrl.startsWith('/uploads')) {
result = `${PUBLIC_BASE_URL}${imageUrl}`;
if (apiStatus.debugMode) {
// console.log(`Обработка изображения /uploads: ${imageUrl} -> ${result}`);
// Если это старый путь /uploads (на всякий случай, если где-то остались)
if (imageUrl.startsWith('/uploads/')) {
// В идеале, этот блок нужно будет удалить после миграции всех данных
const oldUrl = `${PUBLIC_BASE_URL}${imageUrl}`;
if (apiStatus.debugMode) {
// console.warn(`Обнаружен старый формат URL /uploads/: ${imageUrl}. Формируется URL: ${oldUrl}`);
}
return result;
return oldUrl;
}
// Если путь начинается с '/', это относительный путь
if (imageUrl.startsWith('/')) {
result = `${PUBLIC_BASE_URL}${imageUrl}`;
// Если есть базовый URL MinIO и imageUrl не пустой - формируем полный URL
if (minioBaseUrl && typeof imageUrl === 'string' && imageUrl.trim() !== '') {
// Убираем возможный слэш в конце базового URL и в начале ключа
const cleanBaseUrl = minioBaseUrl.endsWith('/') ? minioBaseUrl.slice(0, -1) : minioBaseUrl;
const cleanImageUrl = imageUrl.startsWith('/') ? imageUrl.slice(1) : imageUrl;
const fullUrl = `${cleanBaseUrl}/${cleanImageUrl}`;
if (apiStatus.debugMode) {
// console.log(`Обработка относительного пути: ${imageUrl} -> ${result}`);
// console.log(`Формирование URL MinIO: ${minioBaseUrl} + ${imageUrl} -> ${fullUrl}`);
}
return result;
return fullUrl;
}
// Для всех остальных случаев (например, 'uploads/...')
if (imageUrl.startsWith('uploads/')) {
result = `${PUBLIC_BASE_URL}/${imageUrl}`;
if (apiStatus.debugMode) {
// console.log(`Обработка пути uploads: ${imageUrl} -> ${result}`);
}
return result;
}
// Во всех остальных случаях, предполагаем что это путь к загруженному файлу
result = `${PUBLIC_BASE_URL}/uploads/${imageUrl}`;
// Если базовый URL MinIO не задан или ключ пустой, возвращаем плейсхолдер
if (apiStatus.debugMode) {
// console.log(`Обработка произвольного пути: ${imageUrl} -> ${result}`);
// console.warn(`Не удалось сформировать URL MinIO (базовый URL: ${minioBaseUrl}, ключ: ${imageUrl}). Возвращаю плейсхолдер.`);
}
return result;
return placeholder;
}
/**