diff --git a/build.gradle b/build.gradle index 87169b1..c9b5cfd 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,12 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + + // amazon + implementation platform('software.amazon.awssdk:bom:2.25.17') + + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:auth' } // --- QueryDSL --- def generated = 'build/generated/sources/annotationProcessor/java/main' diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java b/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java new file mode 100644 index 0000000..3f70401 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/exception/ImageException.java @@ -0,0 +1,11 @@ +package com.eatsfine.eatsfine.domain.image.exception; + + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class ImageException extends GeneralException { + public ImageException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java new file mode 100644 index 0000000..f200815 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.image.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ImageErrorStatus implements BaseErrorCode { + EMPTY_FILE(HttpStatus.BAD_REQUEST, "IMAGE4001", "업로드할 파일이 비어 있습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "IMAGE4002", "지원하지 않는 파일 형식입니다."), + S3_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "IMAGE5001", "이미지 업로드에 실패했습니다."), + _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(true) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java index 6527f47..aab3686 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/controller/StoreController.java @@ -12,7 +12,9 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Store", description = "식당 조회 및 관리 API") @RestController @@ -71,4 +73,30 @@ public ApiResponse updateStoreBasicInfo( } + @Operation( + summary = "식당 대표 이미지 등록", + description = "식당의 대표 이미지를 등록합니다." + ) + @PostMapping( + value = "/stores/{storeId}/main-image", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public ApiResponse uploadMainImage( + @RequestPart("mainImage")MultipartFile mainImage, + @PathVariable Long storeId + ){ + return ApiResponse.of(StoreSuccessStatus._STORE_MAIN_IMAGE_UPLOAD_SUCCESS, storeCommandService.uploadMainImage(storeId, mainImage)); + } + + @Operation( + summary = "식당 대표 이미지 조회", + description = "식당의 대표 이미지를 조회합니다." + ) + @GetMapping("/stores/{storeId}/main-image") + public ApiResponse getMainImage( + @PathVariable Long storeId + ) { + return ApiResponse.of(StoreSuccessStatus._STORE_MAIN_IMAGE_GET_SUCCESS, storeQueryService.getMainImage(storeId)); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 11f7ac4..962c97a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java @@ -4,8 +4,8 @@ import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.entity.Store; +import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; import java.util.Collections; import java.util.List; @@ -26,7 +26,7 @@ public static StoreResDto.StoreSearchDto toSearchDto(Store store, Double distanc .rating(store.getRating()) .reviewCount(null) // 리뷰 도메인 구현 이후 추가 예정 .distance(distance) - .mainImageUrl(store.getMainImageUrl()) + .mainImageUrl(store.getMainImageKey()) .isOpenNow(isOpenNow) .build(); } @@ -46,7 +46,7 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .category(store.getCategory()) .rating(store.getRating()) .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 - .mainImageUrl(store.getMainImageUrl()) + .mainImageUrl(store.getMainImageKey()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 .depositAmount(store.calculateDepositAmount()) .businessHours( @@ -65,5 +65,19 @@ public static StoreResDto.StoreUpdateDto toUpdateDto(Long storeId, List .updatedFields(updatedFields) .build(); } + + public static StoreResDto.UploadMainImageDto toUploadMainImageDto(Long storeId, String mainImageUrl) { + return StoreResDto.UploadMainImageDto.builder() + .storeId(storeId) + .mainImageUrl(mainImageUrl) + .build(); + } + + public static StoreResDto.GetMainImageDto toGetMainImageDto(Long storeId, String key) { + return StoreResDto.GetMainImageDto.builder() + .storeId(storeId) + .mainImageUrl(key) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java index a674eaa..c9ee44a 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreResDto.java @@ -70,7 +70,8 @@ public record StoreDetailDto( // 가게 대표 이미지 등록 응답 @Builder - public record uploadMainImageResDto( + public record UploadMainImageDto( + Long storeId, String mainImageUrl ) {} @@ -80,5 +81,11 @@ public record StoreUpdateDto( Long storeId, List updatedFields ){}; + // 가게 대표 이미지 조회 응답 + @Builder + public record GetMainImageDto( + Long storeId, + String mainImageUrl + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 9f8dc34..fcd2bb2 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -66,7 +66,7 @@ public class Store extends BaseEntity { private String address; @Column(name = "main_image_url") - private String mainImageUrl; + private String mainImageKey; @Builder.Default @Column(name = "rating", precision = 2, scale = 1, nullable = false) @@ -95,7 +95,6 @@ public class Store extends BaseEntity { @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) private List tableImages = new ArrayList<>(); - // StoreTable이 아닌 TableLayout 엔티티 참조 @Builder.Default @OneToMany(mappedBy = "store") @@ -130,6 +129,11 @@ public void removeTableImage(TableImage tableImage) { tableImage.assignStore(null); } + // 가게 메인 이미지 등록 + public void updateMainImageKey(String mainImageKey) { + this.mainImageKey = mainImageKey; + } + // 특정 요일의 영업시간 조회 메서드 public BusinessHours getBusinessHoursByDay(DayOfWeek dayOfWeek) { return this.businessHours.stream() @@ -146,6 +150,7 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } + // 예약금 계산 메서드 public BigDecimal calculateDepositAmount() { return BigDecimal.valueOf(minPrice) .multiply(BigDecimal.valueOf(depositRate.getPercent())) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java index 65ab017..33d3918 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandService.java @@ -2,8 +2,10 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreReqDto; import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +import org.springframework.web.multipart.MultipartFile; public interface StoreCommandService { StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto storeCreateDto); StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.StoreUpdateDto storeUpdateDto); + StoreResDto.UploadMainImageDto uploadMainImage(Long storeId, MultipartFile file); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java index 982f0be..7ceae86 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreCommandServiceImpl.java @@ -3,6 +3,8 @@ import com.eatsfine.eatsfine.domain.businesshours.converter.BusinessHoursConverter; import com.eatsfine.eatsfine.domain.businesshours.entity.BusinessHours; import com.eatsfine.eatsfine.domain.businesshours.validator.BusinessHoursValidator; +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.region.entity.Region; import com.eatsfine.eatsfine.domain.region.repository.RegionRepository; import com.eatsfine.eatsfine.domain.region.status.RegionErrorStatus; @@ -19,6 +21,8 @@ import java.util.ArrayList; import java.util.List; +import com.eatsfine.eatsfine.global.s3.S3Service; +import org.springframework.web.multipart.MultipartFile; @Service @Transactional @@ -27,7 +31,9 @@ public class StoreCommandServiceImpl implements StoreCommandService { private final StoreRepository storeRepository; private final RegionRepository regionRepository; + private final S3Service s3Service; + // 가게 등록 @Override public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { Region region = regionRepository.findById(dto.regionId()) @@ -42,7 +48,7 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .businessNumber(dto.businessNumber()) .description(dto.description()) .address(dto.address()) - .mainImageUrl(null) // 별도 API로 구현 + .mainImageKey(null) // 별도 API로 구현 .region(region) .phoneNumber(dto.phoneNumber()) .category(dto.category()) @@ -77,15 +83,37 @@ public StoreResDto.StoreUpdateDto updateBasicInfo(Long storeId, StoreReqDto.Stor public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { List updated = new ArrayList<>(); - if(dto.storeName() != null) updated.add("storeName"); - if(dto.description() != null) updated.add("description"); - if(dto.phoneNumber() != null) updated.add("phoneNumber"); - if(dto.category() != null) updated.add("category"); - if(dto.minPrice() != null) updated.add("minPrice"); - if(dto.depositRate() != null) updated.add("depositRate"); - if(dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); + if (dto.storeName() != null) updated.add("storeName"); + if (dto.description() != null) updated.add("description"); + if (dto.phoneNumber() != null) updated.add("phoneNumber"); + if (dto.category() != null) updated.add("category"); + if (dto.minPrice() != null) updated.add("minPrice"); + if (dto.depositRate() != null) updated.add("depositRate"); + if (dto.bookingIntervalMinutes() != null) updated.add("bookingIntervalMinutes"); return updated; } + // 가게 메인 이미지 등록 + @Override + public StoreResDto.UploadMainImageDto uploadMainImage(Long storeId, MultipartFile file) { + Store store = storeRepository.findById(storeId).orElseThrow( + () -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND) + ); + + if(file.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + if(store.getMainImageKey() != null) { + s3Service.deleteByKey(store.getMainImageKey()); + } + + String key = s3Service.upload(file, "stores/" + storeId + "/main"); + store.updateMainImageKey(key); + + String mainImageUrl = s3Service.toUrl(store.getMainImageKey()); + + return StoreConverter.toUploadMainImageDto(store.getId(), mainImageUrl); + } } \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java index 8171953..513f7cf 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryService.java @@ -17,6 +17,8 @@ StoreResDto.StoreSearchResDto search( StoreResDto.StoreDetailDto getStoreDetail(Long storeId); + StoreResDto.GetMainImageDto getMainImage(Long storeId); + boolean isOpenNow(Store store, LocalDateTime now); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java index 2c93816..fd06161 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/service/StoreQueryServiceImpl.java @@ -5,16 +5,16 @@ import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; import com.eatsfine.eatsfine.domain.store.dto.projection.StoreSearchResult; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.domain.store.enums.Category; -import com.eatsfine.eatsfine.domain.store.enums.StoreSortType; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.DayOfWeek; import java.time.LocalDateTime; @@ -23,9 +23,11 @@ @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class StoreQueryServiceImpl implements StoreQueryService { private final StoreRepository storeRepository; + private final S3Service s3Service; // 식당 검색 @Override @@ -73,6 +75,15 @@ public StoreResDto.StoreDetailDto getStoreDetail(Long storeId) { return StoreConverter.toDetailDto(store, isOpenNow(store, LocalDateTime.now())); } + // 식당 대표 이미지 조회 + @Override + public StoreResDto.GetMainImageDto getMainImage(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + return StoreConverter.toGetMainImageDto(storeId, s3Service.toUrl(store.getMainImageKey())); + } + // 현재 영업 여부 계산 (실시간 계산) @Override public boolean isOpenNow(Store store, LocalDateTime now) { diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java index 088f093..b581c92 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreSuccessStatus.java @@ -18,7 +18,11 @@ public enum StoreSuccessStatus implements BaseCode { _STORE_CREATED(HttpStatus.CREATED, "STORE201", "성공적으로 가게를 등록했습니다."), - _STORE_UPDATE_SUCCESS(HttpStatus.OK, "STORE2004", "성공적으로 가게 기본 정보를 수정했습니다.") + _STORE_UPDATE_SUCCESS(HttpStatus.OK, "STORE2004", "성공적으로 가게 기본 정보를 수정했습니다."), + + _STORE_MAIN_IMAGE_UPLOAD_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 업로드했습니다."), + + _STORE_MAIN_IMAGE_GET_SUCCESS(HttpStatus.OK, "STORE2005", "성공적으로 가게 대표 이미지를 조회했습니다.") ; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java new file mode 100644 index 0000000..7ff69f6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/controller/TableImageController.java @@ -0,0 +1,68 @@ +package com.eatsfine.eatsfine.domain.tableimage.controller; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.service.TableImageCommandService; +import com.eatsfine.eatsfine.domain.tableimage.service.TableImageQueryService; +import com.eatsfine.eatsfine.domain.tableimage.status.TableImageSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Tag(name = "TableImage", description = "테이블 이미지 조회 및 관리 API") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class TableImageController { + + private final TableImageCommandService tableImageCommandService; + private final TableImageQueryService tableImageQueryService; + + @Operation( + summary = "식당 테이블 이미지 등록", + description = "식당 테이블 이미지들을 등록합니다." + ) + @PostMapping( + value = "/stores/{storeId}/table-images", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + ApiResponse uploadTableImage( + @RequestPart("file") List files, + @PathVariable Long storeId + ) { + return ApiResponse.of( + TableImageSuccessStatus._STORE_TABLE_IMAGE_UPLOAD_SUCCESS, + tableImageCommandService.uploadTableImage(storeId, files) + ); + } + + @Operation( + summary = "식당 테이블 이미지 조회", + description = "식당 테이블 이미지들을 조회합니다." + ) + @GetMapping("/stores/{storeId}/table-images") + ApiResponse getTableImage( + @PathVariable Long storeId + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_GET_SUCCESS, tableImageQueryService.getTableImage(storeId)); + } + + @Operation( + summary = "식당 테이블 이미지 삭제", + description = "식당 테이블 이미지를 삭제합니다." + ) + @DeleteMapping("/stores/{storeId}/table-images") + ApiResponse deleteTableImage( + @PathVariable Long storeId, + @RequestBody List tableImageIds + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_DELETE_SUCCESS, tableImageCommandService.deleteTableImage(storeId, tableImageIds)); + } + + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java new file mode 100644 index 0000000..9daf0ae --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/converter/TableImageConverter.java @@ -0,0 +1,29 @@ +package com.eatsfine.eatsfine.domain.tableimage.converter; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; + +import java.util.List; + +public class TableImageConverter { + + public static TableImageResDto.UploadTableImageDto toUploadTableImageDto(Long storeId, List tableImages) { + return TableImageResDto.UploadTableImageDto.builder() + .storeId(storeId) + .tableImageUrls(tableImages) + .build(); + } + + public static TableImageResDto.GetTableImageDto toGetTableImageDto(Long storeId, List tableImages) { + return TableImageResDto.GetTableImageDto.builder() + .storeId(storeId) + .tableImageUrls(tableImages) + .build(); + } + + public static TableImageResDto.DeleteTableImageDto toDeleteTableImageDto(Long storeId, List removedTableImages) { + return TableImageResDto.DeleteTableImageDto.builder() + .storeId(storeId) + .deletedTableImageIds(removedTableImages) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java new file mode 100644 index 0000000..baea00d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/dto/TableImageResDto.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.domain.tableimage.dto; + +import lombok.Builder; + +import java.util.List; + +public class TableImageResDto { + + @Builder + public record UploadTableImageDto( + Long storeId, + List tableImageUrls + ){} + + @Builder + public record GetTableImageDto( + Long storeId, + List tableImageUrls + ){} + + @Builder + public record DeleteTableImageDto( + Long storeId, + List deletedTableImageIds + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java index 1bb000e..d13b237 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -22,7 +22,10 @@ public class TableImage extends BaseEntity { private Store store; @Column(name = "table_image_url", nullable = false) - private String tableImageUrl; + private String tableImageKey; + + @Column(name = "image_order", nullable = false) + private int imageOrder; public void assignStore(Store store) { this.store = store; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java index dda63a2..414733b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/repository/TableImageRepository.java @@ -1,7 +1,23 @@ package com.eatsfine.eatsfine.domain.tableimage.repository; +import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; public interface TableImageRepository extends JpaRepository { + + @Query(""" + select coalesce(max(ti.imageOrder), 0) + from TableImage ti + where ti.store.id = :storeId +""") + int findMaxOrderByStoreId(Long storeId); + + List findAllByStoreOrderByImageOrder(Store store); + + Optional findByIdAndStore(Long id, Store store); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java new file mode 100644 index 0000000..be27464 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandService.java @@ -0,0 +1,13 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface TableImageCommandService { + + TableImageResDto.UploadTableImageDto uploadTableImage(Long storeId, List files); + + TableImageResDto.DeleteTableImageDto deleteTableImage(Long storeId, List tableImageIds); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java new file mode 100644 index 0000000..bc72c8b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageCommandServiceImpl.java @@ -0,0 +1,74 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.tableimage.converter.TableImageConverter; +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; +import com.eatsfine.eatsfine.domain.tableimage.repository.TableImageRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class TableImageCommandServiceImpl implements TableImageCommandService { + + private final StoreRepository storeRepository; + private final TableImageRepository tableImageRepository; + private final S3Service s3Service; + + // 가게 테이블 이미지 등록 + public TableImageResDto.UploadTableImageDto uploadTableImage(Long storeId, List files) { + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + if(files == null || files.isEmpty() || files.stream().allMatch(MultipartFile::isEmpty)) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + int imageOrder = tableImageRepository.findMaxOrderByStoreId(storeId) + 1; + List tableImages = new ArrayList<>(); + + for (MultipartFile file : files) { + String key = s3Service.upload(file, "stores/" + storeId + "/tables"); + TableImage tableImage = TableImage.builder() + .tableImageKey(key) + .imageOrder(imageOrder++) + .build(); + store.addTableImage(tableImage); + tableImages.add(s3Service.toUrl(key)); + } + return TableImageConverter.toUploadTableImageDto(storeId, tableImages); + } + + @Override + public TableImageResDto.DeleteTableImageDto deleteTableImage(Long storeId, List tableImageIds) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List tableImages = tableImageIds.stream() + .map(id -> tableImageRepository.findByIdAndStore(id, store) + .orElseThrow(() -> new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND))) + .toList(); + + for (TableImage tableImage : tableImages) { + s3Service.deleteByKey(tableImage.getTableImageKey()); + store.removeTableImage(tableImage); + } + + return TableImageConverter.toDeleteTableImageDto(storeId, tableImageIds); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java new file mode 100644 index 0000000..52a0602 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; + +public interface TableImageQueryService { + TableImageResDto.GetTableImageDto getTableImage(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java new file mode 100644 index 0000000..a2c19d9 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/service/TableImageQueryServiceImpl.java @@ -0,0 +1,41 @@ +package com.eatsfine.eatsfine.domain.tableimage.service; + +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.store.exception.StoreException; +import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; +import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; +import com.eatsfine.eatsfine.domain.tableimage.converter.TableImageConverter; +import com.eatsfine.eatsfine.domain.tableimage.dto.TableImageResDto; +import com.eatsfine.eatsfine.domain.tableimage.entity.TableImage; +import com.eatsfine.eatsfine.domain.tableimage.repository.TableImageRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TableImageQueryServiceImpl implements TableImageQueryService { + + private final StoreRepository storeRepository; + private final TableImageRepository tableImageRepository; + private final S3Service s3Service; + + @Override + public TableImageResDto.GetTableImageDto getTableImage(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List tableImages = tableImageRepository.findAllByStoreOrderByImageOrder(store); + + List tableImageUrls = tableImages.stream() + .map(ti-> s3Service.toUrl(ti.getTableImageKey())) + .toList(); + + return TableImageConverter.toGetTableImageDto(storeId, tableImageUrls); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java new file mode 100644 index 0000000..058f30b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/status/TableImageSuccessStatus.java @@ -0,0 +1,46 @@ +package com.eatsfine.eatsfine.domain.tableimage.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TableImageSuccessStatus implements BaseCode { + + _STORE_TABLE_IMAGE_UPLOAD_SUCCESS(HttpStatus.OK, "TABLE_IMAGE200", "성공적으로 가게 테이블 이미지를 업로드했습니다."), + + _STORE_TABLE_IMAGE_GET_SUCCESS(HttpStatus.OK, "TABLE_IMAGE2001", "성공적으로 가게 테이블 이미지를 조회했습니다."), + + _STORE_TABLE_IMAGE_DELETE_SUCCESS(HttpStatus.OK, "TABLE_IMAGE2002", "성공적으로 가게 테이블 이미지를 삭제했습니다.") + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDto getReason() { + return ReasonDto.builder() + .isSuccess(true) + .message(message) + .code(code) + .build(); + } + + @Override + public ReasonDto getReasonHttpStatus() { + return ReasonDto.builder() + .isSuccess(true) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } + + } + + diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java b/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java new file mode 100644 index 0000000..b8c5c14 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/S3Config.java @@ -0,0 +1,17 @@ +package com.eatsfine.eatsfine.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.AP_NORTHEAST_2) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java new file mode 100644 index 0000000..774fc36 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java @@ -0,0 +1,76 @@ +package com.eatsfine.eatsfine.global.s3; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3Service { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.base-url}") + private String baseUrl; + + public String upload(MultipartFile file, String directory) { + String key = generateKey(file, directory); + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return key; + } catch (IOException e) { + throw new ImageException(ImageErrorStatus.S3_UPLOAD_FAILED); + } + } + + public void deleteByKey(String key) { + if (key == null || key.isBlank()) return; + + s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } + + public String toUrl(String key) { + if (key == null || key.isBlank()) return null; + return baseUrl + "/" + key; + } + + private String generateKey(MultipartFile file, String directory) { + if(directory == null || directory.isBlank()) { + throw new IllegalArgumentException("S3 디렉토리는 비어있을 수 없습니다."); + } + String extension = extractExtension(file.getOriginalFilename()); + return directory + "/" + UUID.randomUUID() + extension; + } + + private String extractExtension(String filename) { + if (filename == null || !filename.contains(".")) { + throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); + } + return filename.substring(filename.lastIndexOf(".")); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b6cae83..2338f34 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,3 +3,10 @@ spring: name: Eatsfine profiles: active: local + +cloud: + aws: + region: ${AWS_REGION} + s3: + bucket: ${AWS_S3_BUCKET} + base-url: ${AWS_S3_BASE_URL} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 32c915a..addc334 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -5,4 +5,4 @@ server: spring: config: activate: - on-profile: test + on-profile: test \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c005f2e..e65dfae 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -15,4 +15,11 @@ spring: payment: toss: - widget-secret-key: test_sk_sample_key_for_testing \ No newline at end of file + widget-secret-key: test_sk_sample_key_for_testing + +cloud: + aws: + region: test-region + s3: + bucket: test-bucket + base-url: https://test-bucket.s3.test-region.amazonaws.com \ No newline at end of file