diff --git a/README.md b/README.md index 5e97794..474842d 100644 --- a/README.md +++ b/README.md @@ -1 +1,9 @@ -# Backend \ No newline at end of file +# Backend + +## Запуск docker-compose + +Находясь в корневой папке проекта: +1. `./gradlew build` - Для сборки проекта +2. `docker build -f Dockerfile .` - Для сборки Dockerfile +3. `docker compose build` - Для сборки docker-compose +4. `docker compose up` - Запуск docker-compose \ No newline at end of file diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/RentPlaceApplication.java b/rentplace/src/main/java/kattsyn/dev/rentplace/RentPlaceApplication.java index 987746d..d087d92 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/RentPlaceApplication.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/RentPlaceApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.PropertySource; @SpringBootApplication +@PropertySource("classpath:application.yml") public class RentPlaceApplication { public static void main(String[] args) { diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/CategoryController.java b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/CategoryController.java index d52281d..7a63c69 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/CategoryController.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/CategoryController.java @@ -9,10 +9,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kattsyn.dev.rentplace.dtos.CategoryDTO; import kattsyn.dev.rentplace.entities.Category; +import kattsyn.dev.rentplace.entities.Image; import kattsyn.dev.rentplace.services.CategoryService; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -106,4 +109,25 @@ public ResponseEntity deleteCategory( ) { return ResponseEntity.ok(categoryService.deleteById(id)); } + + @Operation( + summary = "Загрузка фотографии для категории", + description = "Загрузка фотографии для категории" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешно", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Image.class))), + @ApiResponse(responseCode = "500", description = "Непредвиденная ошибка со стороны сервера", content = @Content) + }) + @PostMapping(path = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @Parameter( + description = "Файл фотографии", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) @RequestParam("file") MultipartFile file, + @PathVariable + @Parameter(description = "id категории", example = "10") long id) { + categoryService.uploadImage(file, id); + return ResponseEntity.ok().build(); + } } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/FacilityController.java b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/FacilityController.java index c7b9b24..c7eec2e 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/FacilityController.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/FacilityController.java @@ -10,10 +10,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kattsyn.dev.rentplace.dtos.FacilityDTO; import kattsyn.dev.rentplace.entities.Facility; +import kattsyn.dev.rentplace.entities.Image; import kattsyn.dev.rentplace.services.FacilityService; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -25,6 +28,27 @@ public class FacilityController { private final FacilityService facilityService; + @Operation( + summary = "Загрузка фотографии для категории", + description = "Загрузка фотографии для категории" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешно", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Image.class))), + @ApiResponse(responseCode = "500", description = "Непредвиденная ошибка со стороны сервера", content = @Content) + }) + @PostMapping(path = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @Parameter( + description = "Файл фотографии", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) @RequestParam("file") MultipartFile file, + @PathVariable + @Parameter(description = "id категории", example = "10") long id) { + facilityService.uploadImage(file, id); + return ResponseEntity.ok().build(); + } + @Operation(summary = "Получение всех удобств", description = "Получение всех удобств") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Успешно", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Facility[].class))), diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/ImageController.java b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/ImageController.java new file mode 100644 index 0000000..0c8dab2 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/ImageController.java @@ -0,0 +1,129 @@ +package kattsyn.dev.rentplace.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import kattsyn.dev.rentplace.entities.Image; +import kattsyn.dev.rentplace.enums.ImageType; +import kattsyn.dev.rentplace.services.ImageService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +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 java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("${api.path}/images") +@RequiredArgsConstructor +@Tag(name = "ImageController", description = "Для взаимодействия с фотографиями") +public class ImageController { + + private final ImageService imageService; + + @Operation( + summary = "Загрузка фотографии", + description = "Загрузка фотографии" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешно", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Image.class))), + @ApiResponse(responseCode = "500", description = "Непредвиденная ошибка со стороны сервера", content = @Content) + }) + @PostMapping(path = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadImage( + @Parameter( + description = "Файл фотографии", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) @RequestParam("file") MultipartFile file, + @Parameter( + description = "Тип изображения", + required = true, + schema = @Schema( + implementation = ImageType.class) + ) @RequestParam ImageType imageType) { + return ResponseEntity.ok(imageService.uploadImage(file, imageType)); + } + + + + @PostMapping(value = "/multiple/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Массовая загрузка фотографий", + description = "Загружает несколько фотографий за один запрос. Максимальное количество файлов - 10. Максимальный размер каждого файла - 10MB.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Фотографии успешно загружены", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Image[].class))), + @ApiResponse(responseCode = "400", description = "Некорректный запрос (превышено кол-во файлов, неверный формат и т.д.)", content = @Content), + @ApiResponse(responseCode = "413", description = "Превышен максимальный размер запроса"), + @ApiResponse(responseCode = "500", description = "Внутренняя ошибка сервера") + }) + public ResponseEntity> uploadMultipleImages(@Parameter( + description = "Массив файлов фотографий", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, schema = @Schema(type = "string", format = "binary")) + ) @RequestPart("files") MultipartFile[] files) { + List savedImages = new ArrayList<>(); + + for (MultipartFile file : files) { + savedImages.add(imageService.uploadImage(file)); + } + + return ResponseEntity.ok(savedImages); + } + + @Operation( + summary = "Получить фотографию", + description = "Получить фотографию по id" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Успешно", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = Resource.class)) + }), + @ApiResponse(responseCode = "400", description = "Получен некорректный ID", content = @Content), + @ApiResponse(responseCode = "404", description = "Фотография не найдена", content = @Content), + @ApiResponse(responseCode = "500", description = "Непредвиденная ошибка со стороны сервера", content = @Content) + }) + @GetMapping("/{id}") + public ResponseEntity getImage( + @Parameter(description = "ID фотографии", required = true, example = "1") + @PathVariable long id) { + Image image = imageService.getImageById(id); + Resource file = imageService.getImageResource(image); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + image.getOriginalFileName() + "\"") + .contentType(MediaType.parseMediaType(image.getContentType())) + .body(file); + } + + @DeleteMapping("/{id}") + @Operation( + summary = "Удалить фотографию", + description = "Удаляет фотографию и её метаданные", + responses = { + @ApiResponse( + responseCode = "200", + description = "Фото успешно удалено"), + @ApiResponse( + responseCode = "404", + description = "Фото не найдено") + } + ) + public ResponseEntity deleteImage( + @Parameter(description = "ID фотографии", required = true, example = "1") + @PathVariable Long id) { + + imageService.deleteImage(id); + + return ResponseEntity.ok().build(); + } +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/PropertyController.java b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/PropertyController.java index ff51c91..55ab06c 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/PropertyController.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/controllers/PropertyController.java @@ -8,12 +8,18 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import kattsyn.dev.rentplace.dtos.PropertyDTO; +import kattsyn.dev.rentplace.entities.Image; import kattsyn.dev.rentplace.entities.Property; +import kattsyn.dev.rentplace.enums.ImageType; +import kattsyn.dev.rentplace.services.ImageService; import kattsyn.dev.rentplace.services.PropertyService; +import kattsyn.dev.rentplace.utils.PathResolver; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -24,6 +30,40 @@ public class PropertyController { private final PropertyService propertyService; + private final ImageService imageService; + + @PostMapping(value = "/{id}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Массовая загрузка фотографий", + description = "Загружает несколько фотографий за один запрос. Максимальное количество файлов - 10. Максимальный размер каждого файла - 10MB.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Фотографии успешно загружены", content = @Content(mediaType = "application/json", schema = @Schema(implementation = Image[].class))), + @ApiResponse(responseCode = "400", description = "Некорректный запрос (превышено кол-во файлов, неверный формат и т.д.)", content = @Content), + @ApiResponse(responseCode = "413", description = "Превышен максимальный размер запроса"), + @ApiResponse(responseCode = "500", description = "Внутренняя ошибка сервера") + }) + public ResponseEntity> uploadMultipleImages( + @PathVariable @Parameter(description = "id объявления", example = "10") long id, + @Parameter( + description = "Массив файлов фотографий", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, schema = @Schema(type = "string", format = "binary")) + ) @RequestPart("files") MultipartFile[] files) { + + + List savedImages = propertyService.uploadImages(files, id); + + return ResponseEntity.ok(savedImages); + } + + @PostMapping("/{id}/image") + public Image uploadPropertyImage( + @PathVariable Long id, + @RequestParam MultipartFile file) { + + String path = PathResolver.resolvePath(ImageType.PROPERTY, id); + return imageService.uploadImage(file, path); + } @Operation( summary = "Получение всех объявлений", @@ -106,6 +146,4 @@ public ResponseEntity updateProperty(@PathVariable @Parameter(descript public ResponseEntity deleteProperty(@PathVariable @Parameter(description = "id объявления", example = "10") long id) { return new ResponseEntity<>(propertyService.deleteById(id), HttpStatus.NO_CONTENT); } - - } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Category.java b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Category.java index 832ea1b..3202761 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Category.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Category.java @@ -24,4 +24,8 @@ public class Category { @Schema(description = "Название категории", example = "Кемпинг") private String name; + @OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE}) + @JoinColumn(name = "image_id") + private Image image; + } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Facility.java b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Facility.java index 5683d4e..6f7eb05 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Facility.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Facility.java @@ -24,4 +24,7 @@ public class Facility { @Column(name = "name") private String name; + @OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REMOVE}) + @JoinColumn(name = "image_id") + private Image image; } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Image.java b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Image.java new file mode 100644 index 0000000..1a855b6 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Image.java @@ -0,0 +1,33 @@ +package kattsyn.dev.rentplace.entities; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@NoArgsConstructor +@Getter +@Setter +@Table(name = "images") +public class Image { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "image_id") + private long imageId; + + @Column(name = "file_name", length = 400) + private String fileName; + @Column(name = "original_file_name") + private String originalFileName; + @Column(name = "content_type") + private String contentType; + @Column(name = "additional_path") + private String additionalPath; + @Column(name = "size") + private long size; + @Column(name = "is_preview_image") + private boolean isPreviewImage; + +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Property.java b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Property.java index 291c5b5..9f3e310 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Property.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/entities/Property.java @@ -4,6 +4,8 @@ import jakarta.persistence.*; import lombok.*; +import java.util.List; + @Entity @AllArgsConstructor @NoArgsConstructor @@ -21,7 +23,7 @@ public class Property { @Schema(description = "Адрес имущества", example = "Россия, Воронеж, ул. Новосибирская, д.21") private String address; - @Column(name = "description") + @Column(name = "description", length = 2000) @Schema(description = "Описание имущества", example = "Уютная квартира с видом на водохранилище") private String description; @@ -53,4 +55,8 @@ public class Property { @Column(name = "max_guests") private int maxGuests; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "property_id") + List images; + } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/enums/ImageType.java b/rentplace/src/main/java/kattsyn/dev/rentplace/enums/ImageType.java new file mode 100644 index 0000000..3166bcc --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/enums/ImageType.java @@ -0,0 +1,26 @@ +package kattsyn.dev.rentplace.enums; + + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + description = "Тип загружаемого изображения", + allowableValues = {"PROPERTY", "USER", "FACILITY", "CATEGORY"}, + example = "PROPERTY" +) +public enum ImageType { + @Schema(description = "Изображение недвижимости") + PROPERTY("/properties/"), + @Schema(description = "Изображение пользователя") + USER("/users/"), + @Schema(description = "Изображение удобства") + FACILITY("/facilities/"), + @Schema(description = "Изображение категории") + CATEGORY("/categories/"),; + + public final String additionalPath; + + ImageType(String additionalPath) { + this.additionalPath = additionalPath; + } +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/properties/StorageProperties.java b/rentplace/src/main/java/kattsyn/dev/rentplace/properties/StorageProperties.java new file mode 100644 index 0000000..a0fb924 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/properties/StorageProperties.java @@ -0,0 +1,12 @@ +package kattsyn.dev.rentplace.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "storage") +@Getter +@Setter +public class StorageProperties { + private String location = "./uploads/"; +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/repositories/ImageRepository.java b/rentplace/src/main/java/kattsyn/dev/rentplace/repositories/ImageRepository.java new file mode 100644 index 0000000..5be38ea --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/repositories/ImageRepository.java @@ -0,0 +1,7 @@ +package kattsyn.dev.rentplace.repositories; + +import kattsyn.dev.rentplace.entities.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository { +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/CategoryService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/CategoryService.java index 2a70a73..392a18f 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/CategoryService.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/CategoryService.java @@ -2,6 +2,7 @@ import kattsyn.dev.rentplace.dtos.CategoryDTO; import kattsyn.dev.rentplace.entities.Category; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -13,4 +14,6 @@ public interface CategoryService { Category update(Long id, CategoryDTO categoryDTO); Category deleteById(Long id); + void uploadImage(MultipartFile file, long id); + } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/FacilityService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/FacilityService.java index dc7cc3c..cd0c561 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/FacilityService.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/FacilityService.java @@ -2,6 +2,7 @@ import kattsyn.dev.rentplace.dtos.FacilityDTO; import kattsyn.dev.rentplace.entities.Facility; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -13,4 +14,5 @@ public interface FacilityService { Facility update(Long id, FacilityDTO facilityDTO); Facility deleteById(Long id); + void uploadImage(MultipartFile file, long id); } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/ImageService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/ImageService.java new file mode 100644 index 0000000..07af135 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/ImageService.java @@ -0,0 +1,17 @@ +package kattsyn.dev.rentplace.services; + +import kattsyn.dev.rentplace.entities.Image; +import kattsyn.dev.rentplace.enums.ImageType; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +public interface ImageService { + Image uploadImage(MultipartFile file); + Image uploadImage(MultipartFile file, ImageType imageType); + Image uploadImage(MultipartFile file, String relativePath); + + void deleteImage(long id); + Image getImageById(long id); + Resource getImageResource(Image image); + +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/PropertyService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/PropertyService.java index 88a438e..ac0971c 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/PropertyService.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/PropertyService.java @@ -1,7 +1,9 @@ package kattsyn.dev.rentplace.services; import kattsyn.dev.rentplace.dtos.PropertyDTO; +import kattsyn.dev.rentplace.entities.Image; import kattsyn.dev.rentplace.entities.Property; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -13,4 +15,5 @@ public interface PropertyService { Property update(long id, PropertyDTO propertyDTO); Property deleteById(long id); + List uploadImages(MultipartFile[] files, long id); } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/StorageService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/StorageService.java new file mode 100644 index 0000000..fa25be6 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/StorageService.java @@ -0,0 +1,14 @@ +package kattsyn.dev.rentplace.services; + + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +public interface StorageService { + + String store(MultipartFile file); + String store(MultipartFile file, String location); + Resource loadAsResource(String filename, String location); + void rollbackUpload(String relativePath, String fileName); + void delete(String relativePath, String filename); +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/CategoryServiceImpl.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/CategoryServiceImpl.java index 88119a1..c140ec9 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/CategoryServiceImpl.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/CategoryServiceImpl.java @@ -3,11 +3,16 @@ import jakarta.transaction.Transactional; import kattsyn.dev.rentplace.dtos.CategoryDTO; import kattsyn.dev.rentplace.entities.Category; +import kattsyn.dev.rentplace.entities.Image; +import kattsyn.dev.rentplace.enums.ImageType; import kattsyn.dev.rentplace.mappers.CategoryMapper; import kattsyn.dev.rentplace.repositories.CategoryRepository; import kattsyn.dev.rentplace.services.CategoryService; +import kattsyn.dev.rentplace.services.ImageService; +import kattsyn.dev.rentplace.utils.PathResolver; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -17,6 +22,7 @@ public class CategoryServiceImpl implements CategoryService { private final CategoryRepository categoryRepository; private final CategoryMapper categoryMapper; + private final ImageService imageService; @Transactional @Override @@ -52,4 +58,18 @@ public Category deleteById(Long id) { categoryRepository.delete(categoryForDeletion); return categoryForDeletion; } + + @Transactional + @Override + public void uploadImage(MultipartFile file, long id) { + Category category = findById(id); + String path = PathResolver.resolvePath(ImageType.CATEGORY, id); + + if (category.getImage() != null) { + imageService.deleteImage(category.getImage().getImageId()); + } + Image image = imageService.uploadImage(file, path); + category.setImage(image); + categoryRepository.save(category); + } } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FacilityServiceImpl.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FacilityServiceImpl.java index 096704f..164c497 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FacilityServiceImpl.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FacilityServiceImpl.java @@ -3,11 +3,16 @@ import jakarta.transaction.Transactional; import kattsyn.dev.rentplace.dtos.FacilityDTO; import kattsyn.dev.rentplace.entities.Facility; +import kattsyn.dev.rentplace.entities.Image; +import kattsyn.dev.rentplace.enums.ImageType; import kattsyn.dev.rentplace.mappers.FacilityMapper; import kattsyn.dev.rentplace.repositories.FacilityRepository; import kattsyn.dev.rentplace.services.FacilityService; +import kattsyn.dev.rentplace.services.ImageService; +import kattsyn.dev.rentplace.utils.PathResolver; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -17,6 +22,7 @@ public class FacilityServiceImpl implements FacilityService { private final FacilityRepository facilityRepository; private final FacilityMapper facilityMapper; + private final ImageService imageService; @Transactional @Override @@ -52,4 +58,18 @@ public Facility deleteById(Long id) { facilityRepository.delete(facilityForDeletion); return facilityForDeletion; } + + @Transactional + @Override + public void uploadImage(MultipartFile file, long id) { + Facility facility = findById(id); + String path = PathResolver.resolvePath(ImageType.FACILITY, id); + + if (facility.getImage() != null) { + imageService.deleteImage(facility.getImage().getImageId()); + } + Image image = imageService.uploadImage(file, path); + facility.setImage(image); + facilityRepository.save(facility); + } } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FileSystemStorageService.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FileSystemStorageService.java new file mode 100644 index 0000000..2fc80c3 --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/FileSystemStorageService.java @@ -0,0 +1,126 @@ +package kattsyn.dev.rentplace.services.impl; + +import kattsyn.dev.rentplace.properties.StorageProperties; +import kattsyn.dev.rentplace.services.StorageService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.stream.Stream; + +@Service +@Slf4j +@EnableConfigurationProperties(StorageProperties.class) +public class FileSystemStorageService implements StorageService { + + private final Path rootLocation; + private final StorageProperties storageProperties; + + public FileSystemStorageService(StorageProperties storageProperties) { + this.rootLocation = Paths.get(storageProperties.getLocation()).toAbsolutePath().normalize(); + init(); + this.storageProperties = storageProperties; + } + + private void init() { + try { + Files.createDirectories(rootLocation); + } catch (IOException e) { + throw new RuntimeException("Could not create directory", e); + } + } + + private void init(Path path) { + try { + Files.createDirectories(path); + } catch (IOException e) { + throw new RuntimeException("Could not create directory", e); + } + } + + @Override + public String store(MultipartFile file) { + String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); + try { + if (file.isEmpty()) { + throw new RuntimeException("Failed to store empty file " + filename); + } + Files.copy(file.getInputStream(), this.rootLocation.resolve(filename)); + return filename; + } catch (IOException e) { + throw new RuntimeException("Failed to store file " + filename, e); + } + } + + //при передаче в location "/properties/prop1/" создалась папка properties, в ней prop1 и там уже фотка + @Override + public String store(MultipartFile file, String relativePath) { + String filename = UUID.randomUUID() + "_" + file.getOriginalFilename(); + Path path = Paths.get(storageProperties.getLocation(), relativePath).toAbsolutePath().normalize(); + init(path); + try { + if (file.isEmpty()) { + throw new RuntimeException("Failed to store empty file " + filename); + } + Files.copy(file.getInputStream(), path.resolve(filename)); + return filename; + } catch (IOException e) { + throw new RuntimeException("Failed to store file " + filename, e); + } + } + + @Override + public void rollbackUpload(String relativePath, String fileName) { + try { + Path filePath = Paths.get(storageProperties.getLocation(), relativePath).resolve(fileName).toAbsolutePath().normalize(); + Files.deleteIfExists(filePath); + + // Удаляем пустую директорию если она пуста + Path dirPath = Paths.get(storageProperties.getLocation(), relativePath).toAbsolutePath().normalize(); + if (Files.isDirectory(dirPath)) { + try (Stream files = Files.list(dirPath)) { + if (files.findAny().isEmpty()) { + Files.delete(dirPath); + } + } + } + } catch (IOException e) { + log.error("Failed to rollback file upload", e); + } + } + + + public Resource loadAsResource(String filename, String location) { + try { + Path file = Paths.get(storageProperties.getLocation(), location).toAbsolutePath().normalize().resolve(filename); + + Resource resource = new UrlResource(file.toUri()); + if (resource.exists() || resource.isReadable()) { + return resource; + } else { + throw new RuntimeException("Could not read file: " + filename); + } + } catch (MalformedURLException e) { + throw new RuntimeException("Could not read file: " + filename, e); + } + } + + + @Override + public void delete(String relativePath, String filename) { + try { + Files.deleteIfExists(Paths.get(storageProperties.getLocation(), relativePath).toAbsolutePath().normalize().resolve(filename)); + } catch (IOException e) { + throw new RuntimeException("Failed to delete file " + filename, e); + } + } +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/ImageServiceImpl.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/ImageServiceImpl.java new file mode 100644 index 0000000..30acc4d --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/ImageServiceImpl.java @@ -0,0 +1,114 @@ +package kattsyn.dev.rentplace.services.impl; + +import jakarta.transaction.Transactional; +import kattsyn.dev.rentplace.entities.Image; +import kattsyn.dev.rentplace.enums.ImageType; +import kattsyn.dev.rentplace.repositories.ImageRepository; +import kattsyn.dev.rentplace.services.ImageService; +import kattsyn.dev.rentplace.services.StorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class ImageServiceImpl implements ImageService { + + private final ImageRepository imageRepository; + private final StorageService storageService; + + private void validateImage(MultipartFile file) { + String contentType = file.getContentType(); + if (file.isEmpty()) { + throw new RuntimeException("Файл пустой"); + } + if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) { + throw new RuntimeException("Поддерживаются только JPEG и PNG изображения"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new RuntimeException("Файл слишком большой"); + } + } + + @Transactional + @Override + public Image uploadImage(MultipartFile file) { + validateImage(file); + + String filename = storageService.store(file); + Image image = new Image(); + image.setFileName(filename); + image.setOriginalFileName(file.getOriginalFilename()); + image.setContentType(file.getContentType()); + image.setSize(file.getSize()); + + return imageRepository.save(image); + } + + @Override + public Image uploadImage(MultipartFile file, ImageType type) { + try { + validateImage(file); + + String filename = storageService.store(file, type.additionalPath); + + Image image = new Image(); + image.setFileName(filename); + image.setOriginalFileName(file.getOriginalFilename()); + image.setContentType(file.getContentType()); + image.setAdditionalPath(type.additionalPath); + image.setSize(file.getSize()); + + return imageRepository.save(image); + } catch (Exception e) { + storageService.rollbackUpload(type.additionalPath, file.getOriginalFilename()); + throw e; + } + } + + @Override + public Image uploadImage(MultipartFile file, String relativePath) { + try { + validateImage(file); + + String filename = storageService.store(file, relativePath); + + Image image = new Image(); + image.setFileName(filename); + image.setOriginalFileName(file.getOriginalFilename()); + image.setContentType(file.getContentType()); + image.setAdditionalPath(relativePath); + image.setSize(file.getSize()); + + return imageRepository.save(image); + } catch (Exception e) { + storageService.rollbackUpload(relativePath, file.getOriginalFilename()); + throw e; + } + } + + @Transactional + @Override + public Image getImageById(long id) { + return imageRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Image not found")); + } + + @Transactional + @Override + public Resource getImageResource(Image image) { + return storageService.loadAsResource(image.getFileName(), image.getAdditionalPath()); + } + + @Transactional + @Override + public void deleteImage(long id) { + Image image = imageRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Image not found")); + + storageService.delete(image.getAdditionalPath(), image.getFileName()); + imageRepository.delete(image); + } + +} diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/PropertyServiceImpl.java b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/PropertyServiceImpl.java index e5c1a54..f23d814 100644 --- a/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/PropertyServiceImpl.java +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/services/impl/PropertyServiceImpl.java @@ -2,13 +2,19 @@ import jakarta.transaction.Transactional; import kattsyn.dev.rentplace.dtos.PropertyDTO; +import kattsyn.dev.rentplace.entities.Image; import kattsyn.dev.rentplace.entities.Property; +import kattsyn.dev.rentplace.enums.ImageType; import kattsyn.dev.rentplace.mappers.PropertyMapper; import kattsyn.dev.rentplace.repositories.PropertyRepository; +import kattsyn.dev.rentplace.services.ImageService; import kattsyn.dev.rentplace.services.PropertyService; +import kattsyn.dev.rentplace.utils.PathResolver; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.ArrayList; import java.util.List; @RequiredArgsConstructor @@ -17,6 +23,7 @@ public class PropertyServiceImpl implements PropertyService { private final PropertyRepository propertyRepository; private final PropertyMapper propertyMapper; + private final ImageService imageService; @Transactional @Override @@ -54,4 +61,20 @@ public Property deleteById(long id) { return propertyForDeletion; } + @Transactional + @Override + public List uploadImages(MultipartFile[] files, long id) { + Property property = findById(id); + + String path = PathResolver.resolvePath(ImageType.PROPERTY, id); + List savedImages = new ArrayList<>(); + + for (MultipartFile file : files) { + Image image = imageService.uploadImage(file, path); + property.getImages().add(image); + savedImages.add(image); + } + return savedImages; + } + } diff --git a/rentplace/src/main/java/kattsyn/dev/rentplace/utils/PathResolver.java b/rentplace/src/main/java/kattsyn/dev/rentplace/utils/PathResolver.java new file mode 100644 index 0000000..efb274f --- /dev/null +++ b/rentplace/src/main/java/kattsyn/dev/rentplace/utils/PathResolver.java @@ -0,0 +1,14 @@ +package kattsyn.dev.rentplace.utils; + +import kattsyn.dev.rentplace.enums.ImageType; + +public class PathResolver { + public static String resolvePath(ImageType imageType, long entityId) { + return switch (imageType){ + case USER -> "/users/" + entityId + "/"; + case PROPERTY -> "/properties/" + entityId + "/"; + case FACILITY -> "/facilities/" + entityId + "/"; + case CATEGORY -> "/categories/" + entityId + "/"; + }; + } +} diff --git a/rentplace/src/main/resources/application.yaml b/rentplace/src/main/resources/application.yml similarity index 55% rename from rentplace/src/main/resources/application.yaml rename to rentplace/src/main/resources/application.yml index 8b98437..fed95a5 100644 --- a/rentplace/src/main/resources/application.yaml +++ b/rentplace/src/main/resources/application.yml @@ -1,4 +1,8 @@ spring: + servlet: + multipart: + max-file-size: 5MB + max-request-size: 10MB application: name: rentplace datasource: @@ -9,6 +13,10 @@ spring: jpa: generate-ddl: true hibernate: - ddl-auto: validate + ddl-auto: update api: - path: api/v1 \ No newline at end of file + path: api/v1 +upload: + path: ./uploads +storage: + location: ./uploads/ \ No newline at end of file diff --git a/rentplace/src/main/resources/db/migration/V202503030016__images_init.sql b/rentplace/src/main/resources/db/migration/V202503030016__images_init.sql new file mode 100644 index 0000000..687fa8b --- /dev/null +++ b/rentplace/src/main/resources/db/migration/V202503030016__images_init.sql @@ -0,0 +1,9 @@ +CREATE TABLE images ( + image_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + file_name VARCHAR(400) NOT NULL, + original_file_name VARCHAR(255) NOT NULL, + additional_path VARCHAR(255) NOT NULL, + size BIGINT NOT NULL, + content_type VARCHAR(255) NOT NULL, + is_preview_image BOOLEAN +); \ No newline at end of file diff --git a/rentplace/src/main/resources/db/migration/V202503161957__property_init.sql b/rentplace/src/main/resources/db/migration/V202503161957__property_init.sql index e877cd1..6bee133 100644 --- a/rentplace/src/main/resources/db/migration/V202503161957__property_init.sql +++ b/rentplace/src/main/resources/db/migration/V202503161957__property_init.sql @@ -2,7 +2,7 @@ CREATE TABLE properties ( property_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, address VARCHAR(255) NOT NULL, - description VARCHAR(65535), + description VARCHAR(2000), rating FLOAT NOT NULL, cost_per_day INTEGER NOT NULL, area FLOAT, diff --git a/rentplace/src/main/resources/db/migration/V202503171403__images_properties_init.sql b/rentplace/src/main/resources/db/migration/V202503171403__images_properties_init.sql new file mode 100644 index 0000000..4e33b78 --- /dev/null +++ b/rentplace/src/main/resources/db/migration/V202503171403__images_properties_init.sql @@ -0,0 +1,10 @@ +CREATE TABLE properties_images( + property_id BIGINT NOT NULL, + image_id BIGINT NOT NULL, + PRIMARY KEY (property_id, image_id), + FOREIGN KEY (property_id) references properties(property_id) ON DELETE CASCADE, + FOREIGN KEY (image_id) references images(image_id) ON DELETE CASCADE +); + +CREATE INDEX idx_property_images_property ON properties_images(property_id); +CREATE INDEX idx_property_images_image ON properties_images(image_id); \ No newline at end of file diff --git a/rentplace/src/main/resources/db/migration/V202503290959__categories_init.sql b/rentplace/src/main/resources/db/migration/V202503290959__categories_init.sql index 6ce0141..f6cd100 100644 --- a/rentplace/src/main/resources/db/migration/V202503290959__categories_init.sql +++ b/rentplace/src/main/resources/db/migration/V202503290959__categories_init.sql @@ -1,5 +1,7 @@ CREATE TABLE categories ( - category_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(255) + category_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255), + image_id BIGINT UNIQUE, + FOREIGN KEY (image_id) REFERENCES images (image_id) ON DELETE SET NULL ); \ No newline at end of file diff --git a/rentplace/src/main/resources/db/migration/V202503292047__facility_init.sql b/rentplace/src/main/resources/db/migration/V202503292047__facility_init.sql index 4aeebe2..de7f146 100644 --- a/rentplace/src/main/resources/db/migration/V202503292047__facility_init.sql +++ b/rentplace/src/main/resources/db/migration/V202503292047__facility_init.sql @@ -1,5 +1,7 @@ CREATE TABLE facilities ( facility_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name VARCHAR(255) + name VARCHAR(255), + image_id BIGINT UNIQUE, + FOREIGN KEY (image_id) REFERENCES images (image_id) ON DELETE SET NULL ); \ No newline at end of file