fixed image locale storage on minio
This commit is contained in:
10
build.gradle
10
build.gradle
@@ -24,6 +24,12 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
@@ -34,6 +40,10 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
|
||||
|
||||
// S3 / MinIO
|
||||
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
|
||||
// JWT
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
|
||||
|
||||
@@ -14,3 +14,30 @@ services:
|
||||
image: redis:7
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
minio:
|
||||
# Используемый образ Docker. Рекомендуется указывать конкретный тег для стабильности.
|
||||
image: minio/minio:RELEASE.2024-04-18T19-09-19Z
|
||||
container_name: minio-example
|
||||
command: server /data --address ":9010" --console-address ":9011"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: admin
|
||||
MINIO_SECRET_KEY: admin123
|
||||
# Проброс портов с хост-машины в контейнер.
|
||||
ports:
|
||||
- "9010:9010" # API port
|
||||
- "9011:9011" # Console port
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1024m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
volumes:
|
||||
minio-data:
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.krylov.refound;
|
||||
|
||||
import com.krylov.refound.config.MinioProperties;
|
||||
import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.krylov.refound")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(
|
||||
scanBasePackages = "com.krylov.refound",
|
||||
exclude = { S3AutoConfiguration.class }
|
||||
)@EnableScheduling
|
||||
@EnableConfigurationProperties(MinioProperties.class)
|
||||
|
||||
public class ReFoundApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
15
src/main/java/com/krylov/refound/config/MinioProperties.java
Normal file
15
src/main/java/com/krylov/refound/config/MinioProperties.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.krylov.refound.config;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
@Validated
|
||||
@ConfigurationProperties(prefix = "minio")
|
||||
public record MinioProperties (
|
||||
@NotBlank String endpoint,
|
||||
@NotBlank String bucket,
|
||||
@NotBlank String accessKey,
|
||||
@NotBlank String secretKey,
|
||||
@NotBlank String region
|
||||
) {}
|
||||
33
src/main/java/com/krylov/refound/config/S3ClientConfig.java
Normal file
33
src/main/java/com/krylov/refound/config/S3ClientConfig.java
Normal file
@@ -0,0 +1,33 @@
|
||||
package com.krylov.refound.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class S3ClientConfig {
|
||||
private final MinioProperties properties;
|
||||
|
||||
@Bean
|
||||
public S3Client s3Client() {
|
||||
return S3Client.builder()
|
||||
.endpointOverride(URI.create(properties.endpoint()))
|
||||
.region(Region.of(properties.region()))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.accessKey(), properties.secretKey())))
|
||||
// ВАЖНО для MinIO: path-style вместо virtual-hosted style
|
||||
.serviceConfiguration(S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(true)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.krylov.refound.controller;
|
||||
|
||||
import com.krylov.refound.service.FileStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileController {
|
||||
private final FileStorageService storageService;
|
||||
|
||||
@PostMapping
|
||||
public String upload(@RequestParam("file") MultipartFile file) {
|
||||
return storageService.save(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.krylov.refound.controller;
|
||||
|
||||
import com.krylov.refound.service.FileStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/files")
|
||||
@RequiredArgsConstructor
|
||||
public class FileStorageController {
|
||||
private final FileStorageService fileStorageService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
|
||||
String objectName = fileStorageService.uploadFile(file);
|
||||
return ResponseEntity.ok(objectName); // фронт сохранит objectName как ключ картинки
|
||||
}
|
||||
|
||||
@GetMapping("/{objectName}")
|
||||
public ResponseEntity<byte[]> downloadFile(@PathVariable String objectName) {
|
||||
FileStorageService.DownloadedFile downloadedFile = fileStorageService.downloadFile(objectName);
|
||||
|
||||
String encodedFilename = UriUtils.encode(downloadedFile.originalFilename(), StandardCharsets.UTF_8);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.contentType(MediaType.parseMediaType(downloadedFile.contentType()))
|
||||
.body(downloadedFile.content());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{objectName}")
|
||||
public ResponseEntity<Void> deleteFile(@PathVariable String objectName) {
|
||||
fileStorageService.deleteFile(objectName);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.krylov.refound.health;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.krylov.refound.config.MinioProperties;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.BucketLifecycleConfiguration;
|
||||
import software.amazon.awssdk.services.s3.model.BucketVersioningStatus;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.ExpirationStatus;
|
||||
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.LifecycleRule;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||
import software.amazon.awssdk.services.s3.model.PutBucketLifecycleConfigurationRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutBucketVersioningRequest;
|
||||
import software.amazon.awssdk.services.s3.model.VersioningConfiguration;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Сервис для управления структурой хранилища в MinIO.
|
||||
* Отвечает за создание необходимого бакета при запуске приложения.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class StorageStructureService {
|
||||
|
||||
private final S3Client s3Client;
|
||||
private final MinioProperties properties;
|
||||
|
||||
/**
|
||||
* Инициализирует структуру хранилища при запуске приложения.
|
||||
* Вызывает метод createBuckets().
|
||||
*/
|
||||
@EventListener(ContextRefreshedEvent.class)
|
||||
public void init() {
|
||||
createBuckets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает основной бакет в MinIO, если он еще не существует.
|
||||
*/
|
||||
public void createBuckets() {
|
||||
log.info("Инициализация основного бакета MinIO...");
|
||||
String mainBucket = properties.bucket();
|
||||
createBucketIfNotExists(mainBucket);
|
||||
log.info("Проверка и создание основного бакета завершены.");
|
||||
|
||||
try {
|
||||
log.info("Включение версионирования для бакета '{}'...", mainBucket);
|
||||
VersioningConfiguration versioningConfiguration = VersioningConfiguration.builder()
|
||||
.status(BucketVersioningStatus.ENABLED)
|
||||
.build();
|
||||
|
||||
PutBucketVersioningRequest request = PutBucketVersioningRequest.builder()
|
||||
.bucket(mainBucket)
|
||||
.versioningConfiguration(versioningConfiguration)
|
||||
.build();
|
||||
|
||||
s3Client.putBucketVersioning(request);
|
||||
log.info("Версионирование для бакета '{}' успешно включено.", mainBucket);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось включить версионирование для бакета '{}': {}", mainBucket, e.getMessage(), e);
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Установка политики жизненного цикла для бакета '{}'...", mainBucket);
|
||||
|
||||
LifecycleRule rule = LifecycleRule.builder()
|
||||
.id("delete-after-one-day-rule")
|
||||
.status(ExpirationStatus.ENABLED)
|
||||
.filter(f -> f.prefix(""))
|
||||
.expiration(exp -> exp.days(1))
|
||||
.build();
|
||||
|
||||
BucketLifecycleConfiguration lifecycleConfig = BucketLifecycleConfiguration.builder()
|
||||
.rules(List.of(rule))
|
||||
.build();
|
||||
|
||||
PutBucketLifecycleConfigurationRequest request = PutBucketLifecycleConfigurationRequest.builder()
|
||||
.bucket(mainBucket)
|
||||
.lifecycleConfiguration(lifecycleConfig)
|
||||
.build();
|
||||
|
||||
s3Client.putBucketLifecycleConfiguration(request);
|
||||
log.info("Политика жизненного цикла для бакета '{}' успешно установлена.", mainBucket);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось установить политику жизненного цикла для бакета '{}': {}", mainBucket, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет существование бакета в MinIO и создает его, если он отсутствует.
|
||||
*
|
||||
* @param bucketName Имя бакета для проверки и создания.
|
||||
*/
|
||||
private void createBucketIfNotExists(String bucketName) {
|
||||
try {
|
||||
log.debug("Проверка существования бакета: {}", bucketName);
|
||||
s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build());
|
||||
log.info("Бакет '{}' уже существует.", bucketName);
|
||||
} catch (NoSuchBucketException e) {
|
||||
log.warn("Бакет '{}' не найден. Попытка создания...", bucketName);
|
||||
try {
|
||||
s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build());
|
||||
log.info("Бакет '{}' успешно создан.", bucketName);
|
||||
} catch (Exception ex) {
|
||||
log.error("Ошибка при создании бакета '{}': {}", bucketName, ex.getMessage(), ex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка при проверке бакета '{}': {}", bucketName, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.krylov.refound.security;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
@@ -28,6 +29,9 @@ public class SecurityConfig {
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/test/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll()
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").permitAll()
|
||||
.requestMatchers(org.springframework.http.HttpMethod.GET, "/api/v1/posts/**").permitAll()
|
||||
.requestMatchers("/api/v1/reviews/**").permitAll()
|
||||
.requestMatchers("/files/**", "/uploads/**").permitAll()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ spring:
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.yaml
|
||||
|
||||
minio:
|
||||
endpoint: http://localhost:9010
|
||||
bucket: refound-images
|
||||
access-key: 59SWvKyRv7VZh4XT0p0H
|
||||
secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K
|
||||
region: us-east-1 # MinIO не проверяет регион, но SDK требует значение
|
||||
|
||||
file:
|
||||
upload-dir: uploads/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user