fixed image locale storage on minio
This commit is contained in:
@@ -1,42 +1,140 @@
|
||||
package com.krylov.refound.service;
|
||||
|
||||
import com.krylov.refound.enums.ErrorCode;
|
||||
import com.krylov.refound.exception.ApiException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import com.krylov.refound.config.MinioProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import software.amazon.awssdk.core.ResponseBytes;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class FileStorageService {
|
||||
|
||||
@Value("${file.upload-dir}")
|
||||
private String uploadDir;
|
||||
private final S3Client s3Client;
|
||||
private final MinioProperties properties;
|
||||
|
||||
public String save(MultipartFile file) {
|
||||
try {
|
||||
if (file.isEmpty()) {
|
||||
throw new RuntimeException("File is empty");
|
||||
}
|
||||
/**
|
||||
* DTO для передачи данных скачанного файла.
|
||||
* @param originalFilename Оригинальное имя файла.
|
||||
* @param contentType MIME-тип файла.
|
||||
* @param content Содержимое файла в виде массива байт.
|
||||
*/
|
||||
public record DownloadedFile(
|
||||
String originalFilename,
|
||||
String contentType,
|
||||
byte[] content
|
||||
) {}
|
||||
|
||||
String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename();
|
||||
/**
|
||||
* Загружает файл в MinIO, сохраняя оригинальное имя в метаданных,
|
||||
* и возвращает его уникальное имя (включая расширение).
|
||||
*
|
||||
* @param file Загружаемый файл.
|
||||
* @return Сгенерированное уникальное имя файла.
|
||||
*/
|
||||
public String uploadFile(MultipartFile file) {
|
||||
try {
|
||||
if (file.isEmpty()) {
|
||||
log.error("Попытка загрузить пустой файл.");
|
||||
throw new RuntimeException("Файл не может быть пустым.");
|
||||
}
|
||||
|
||||
Path path = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
Files.createDirectories(path);
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
|
||||
Path target = path.resolve(fileName);
|
||||
String objectName = UUID.randomUUID().toString() + extension;
|
||||
log.info("Загрузка файла '{}'. Новое имя: {}", originalFilename, objectName);
|
||||
|
||||
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8));
|
||||
|
||||
return "/files/" + fileName; // URL
|
||||
} catch (IOException e) {
|
||||
throw new ApiException(ErrorCode.BAD_REQUEST, "Only image files are allowed", HttpStatus.BAD_REQUEST);
|
||||
PutObjectRequest request = PutObjectRequest.builder()
|
||||
.bucket(properties.bucket())
|
||||
.key(objectName)
|
||||
.contentType(file.getContentType())
|
||||
.metadata(metadata)
|
||||
.build();
|
||||
|
||||
s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
||||
log.info("Файл '{}' успешно загружен.", objectName);
|
||||
return objectName;
|
||||
} catch (IOException e) {
|
||||
log.error("Ошибка при чтении файла для загрузки: {}", e.getMessage());
|
||||
throw new RuntimeException("Ошибка при обработке файла.", e);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось загрузить файл: {}", e.getMessage());
|
||||
throw new RuntimeException("Ошибка при загрузке файла.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает файл из MinIO.
|
||||
*
|
||||
* @param objectName Уникальное имя файла в хранилище.
|
||||
* @return DTO с содержимым файла и его метаданными.
|
||||
*/
|
||||
public DownloadedFile downloadFile(String objectName) {
|
||||
try {
|
||||
// 1. Получаем метаданные объекта, чтобы извлечь оригинальное имя
|
||||
HeadObjectRequest headRequest = HeadObjectRequest.builder()
|
||||
.bucket(properties.bucket())
|
||||
.key(objectName)
|
||||
.build();
|
||||
HeadObjectResponse headResponse = s3Client.headObject(headRequest);
|
||||
// Декодируем имя файла из метаданных
|
||||
String originalFilename = URLDecoder.decode(headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8);
|
||||
String contentType = headResponse.contentType();
|
||||
|
||||
// 2. Получаем сам объект (его содержимое)
|
||||
GetObjectRequest getRequest = GetObjectRequest.builder()
|
||||
.bucket(properties.bucket())
|
||||
.key(objectName)
|
||||
.build();
|
||||
ResponseBytes<GetObjectResponse> responseBytes = s3Client.getObjectAsBytes(getRequest);
|
||||
|
||||
log.info("Файл '{}' (оригинальное имя: '{}') успешно скачан.", objectName, originalFilename);
|
||||
return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при скачивании файла '{}': {}", objectName, e.getMessage());
|
||||
throw new RuntimeException("Ошибка при скачивании файла.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет файл из MinIO по его имени.
|
||||
*
|
||||
* @param objectName Имя файла для удаления.
|
||||
*/
|
||||
public void deleteFile(String objectName) {
|
||||
try {
|
||||
log.info("Запрос на удаление файла: {}", objectName);
|
||||
DeleteObjectRequest request = DeleteObjectRequest.builder()
|
||||
.bucket(properties.bucket())
|
||||
.key(objectName)
|
||||
.build();
|
||||
|
||||
s3Client.deleteObject(request);
|
||||
log.info("Файл '{}' успешно удален.", objectName);
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage());
|
||||
throw new RuntimeException("Ошибка при удалении файла.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,43 +47,30 @@ public class PostService {
|
||||
private final NominatimService nominatimService;
|
||||
private final UserService userService;
|
||||
private final FavoriteRepository favoriteRepository;
|
||||
private final FileStorageService fileStorageService;
|
||||
|
||||
public PostRequest create(PostRequest request, List<MultipartFile> images) {
|
||||
|
||||
Post post = mapper.toEntity(request);
|
||||
post.setCreatedAt(LocalDateTime.now());
|
||||
post.setStatus(PostStatus.ACTIVE);
|
||||
post.setUser(userService.getCurrentUser()); // присваиваем текущего пользователя
|
||||
repository.save(post); // сначала сохраняем post, чтобы получить id
|
||||
post.setUser(userService.getCurrentUser());
|
||||
repository.save(post);
|
||||
|
||||
if (images != null && !images.isEmpty()) {
|
||||
Path uploadDir = Paths.get("uploads");
|
||||
try {
|
||||
Files.createDirectories(uploadDir);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Не удалось создать директорию загрузки", e);
|
||||
}
|
||||
|
||||
for (MultipartFile image : images) {
|
||||
if (image == null || image.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String filename = UUID.randomUUID() + "_" + image.getOriginalFilename();
|
||||
|
||||
try {
|
||||
Files.copy(image.getInputStream(), uploadDir.resolve(filename));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Не удалось сохранить файл: " + filename, e);
|
||||
}
|
||||
String objectKey = fileStorageService.uploadFile(image);
|
||||
|
||||
Image img = new Image();
|
||||
img.setUrl("/uploads/" + filename);
|
||||
img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске
|
||||
img.setPost(post);
|
||||
imageRepository.save(img);
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -187,43 +174,30 @@ public class PostService {
|
||||
|
||||
List<String> keepUrls = existingUrls != null ? existingUrls : List.of();
|
||||
|
||||
// удаляем те картинки, которых нет среди "оставленных" с фронта
|
||||
List<Image> toRemove = post.getImages().stream()
|
||||
.filter(img -> !keepUrls.contains(img.getUrl()))
|
||||
.toList();
|
||||
|
||||
toRemove.forEach(img -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get("." + img.getUrl()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Не удалось удалить файл: {}", img.getUrl());
|
||||
fileStorageService.deleteFile(img.getUrl());
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e);
|
||||
}
|
||||
});
|
||||
|
||||
post.getImages().removeAll(toRemove); // orphanRemoval = true удалит из БД
|
||||
post.getImages().removeAll(toRemove);
|
||||
|
||||
// добавляем новые загруженные файлы
|
||||
if (images != null && !images.isEmpty()) {
|
||||
Path uploadDir = Paths.get("uploads");
|
||||
try {
|
||||
Files.createDirectories(uploadDir);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Не удалось создать директорию загрузки", e);
|
||||
}
|
||||
|
||||
for (MultipartFile image : images) {
|
||||
if (image == null || image.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String filename = UUID.randomUUID() + "_" + image.getOriginalFilename();
|
||||
try {
|
||||
Files.copy(image.getInputStream(), uploadDir.resolve(filename));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Не удалось сохранить файл: " + filename, e);
|
||||
}
|
||||
|
||||
String objectKey = fileStorageService.uploadFile(image);
|
||||
|
||||
Image img = new Image();
|
||||
img.setUrl("/uploads/" + filename);
|
||||
img.setUrl(objectKey);
|
||||
img.setPost(post);
|
||||
post.getImages().add(img);
|
||||
}
|
||||
@@ -235,7 +209,6 @@ public class PostService {
|
||||
|
||||
@CacheEvict(value = {"posts", "post"}, allEntries = true)
|
||||
public void delete(Long id) {
|
||||
|
||||
Post post = repository.findById(id)
|
||||
.orElseThrow(() -> new ApiException(
|
||||
ErrorCode.NOT_FOUND,
|
||||
@@ -243,6 +216,15 @@ public class PostService {
|
||||
HttpStatus.NOT_FOUND
|
||||
));
|
||||
|
||||
// без этого файлы останутся в MinIO мусором после удаления объявления
|
||||
post.getImages().forEach(img -> {
|
||||
try {
|
||||
fileStorageService.deleteFile(img.getUrl());
|
||||
} catch (Exception e) {
|
||||
log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e);
|
||||
}
|
||||
});
|
||||
|
||||
repository.delete(post);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user