diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java index 0b26953..64835ec 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableController.java @@ -5,12 +5,15 @@ import com.eatsfine.eatsfine.domain.storetable.exception.status.StoreTableSuccessStatus; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableCommandService; import com.eatsfine.eatsfine.domain.storetable.service.StoreTableQueryService; +import com.eatsfine.eatsfine.domain.tableimage.status.TableImageSuccessStatus; import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; @@ -66,4 +69,24 @@ public ApiResponse deleteTable( ) { return ApiResponse.of(StoreTableSuccessStatus._TABLE_DELETED, storeTableCommandService.deleteTable(storeId, tableId)); } + + @PostMapping( + value = "/stores/{storeId}/tables/{tableId}/table-image", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE + ) + public ApiResponse uploadTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestPart("tableImage") MultipartFile tableImage + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_UPLOAD_SUCCESS, storeTableCommandService.uploadTableImage(storeId, tableId, tableImage)); + } + + @DeleteMapping("/stores/{storeId}/tables/{tableId}/table-image") + public ApiResponse deleteTableImage( + @PathVariable Long storeId, + @PathVariable Long tableId + ) { + return ApiResponse.of(TableImageSuccessStatus._STORE_TABLE_IMAGE_DELETE_SUCCESS, storeTableCommandService.deleteTableImage(storeId, tableId)); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java index fc4ce97..8e682ef 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/controller/StoreTableControllerDocs.java @@ -5,11 +5,14 @@ import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; 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.responses.ApiResponses; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; import java.time.LocalDate; @@ -187,4 +190,58 @@ ApiResponse deleteTable( @Parameter(description = "테이블 ID", required = true, example = "1") Long tableId ); + + @Operation( + summary = "테이블 이미지 등록", + description = """ + 특정 테이블의 이미지를 등록합니다. + + - 테이블당 1개의 이미지만 등록 가능합니다. + - 기존 이미지가 있는 경우 자동으로 삭제되고 새 이미지로 교체됩니다. + - S3 저장 경로: stores/{storeId}/tables/{tableId}/ + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 등록 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 (빈 파일, 지원하지 않는 파일 형식 등)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게 또는 테이블을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "S3 업로드 실패") + }) + ApiResponse uploadTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId, + + @Parameter( + description = "업로드할 테이블 이미지 파일", + required = true, + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) + MultipartFile tableImage + ); + + @Operation( + summary = "테이블 이미지 삭제", + description = """ + 특정 테이블의 이미지를 삭제합니다. + + - 등록된 이미지가 없는 경우 404 에러가 발생합니다. + - S3에서 이미지가 삭제되고, DB의 이미지 URL도 null로 업데이트됩니다. + - 삭제 후 다시 이미지를 등록할 수 있습니다. + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "테이블 이미지 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "테이블이 해당 가게에 속하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "가게, 테이블을 찾을 수 없거나 이미지가 등록되지 않음") + }) + ApiResponse deleteTableImage( + @Parameter(description = "가게 ID", required = true, example = "1") + Long storeId, + + @Parameter(description = "테이블 ID", required = true, example = "1") + Long tableId + ); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java index 4ef71d7..98619e0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/converter/StoreTableConverter.java @@ -43,12 +43,12 @@ public static StoreTableResDto.SlotListDto toSlotListDto(int totalCount, int ava .build(); } - public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount) { + public static StoreTableResDto.TableDetailDto toTableDetailDto(StoreTable table, LocalDate targetDate, int totalSlotCount, int availableSlotCount, String tableImageUrl) { return StoreTableResDto.TableDetailDto.builder() .tableId(table.getId()) .minSeatCount(table.getMinSeatCount()) .maxSeatCount(table.getMaxSeatCount()) - .tableImageUrl(table.getTableImageUrl()) + .tableImageUrl(tableImageUrl) .rating(table.getRating()) .reviewCount(0) // 리뷰 기능 미구현으로 0 반환 .seatsType(table.getSeatsType()) @@ -94,4 +94,17 @@ public static StoreTableResDto.TableDeleteDto toTableDeleteDto(StoreTable table) .tableId(table.getId()) .build(); } + + public static StoreTableResDto.UploadTableImageDto toUploadTableImageDto(Long tableId, String tableImageUrl) { + return StoreTableResDto.UploadTableImageDto.builder() + .tableId(tableId) + .tableImageUrl(tableImageUrl) + .build(); + } + + public static StoreTableResDto.DeleteTableImageDto toDeleteTableImageDto(Long tableId) { + return StoreTableResDto.DeleteTableImageDto.builder() + .tableId(tableId) + .build(); + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java index 1472a7f..cf04854 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/dto/res/StoreTableResDto.java @@ -82,4 +82,15 @@ public record UpdatedTableDto( public record TableDeleteDto( Long tableId ) {} + + @Builder + public record UploadTableImageDto( + Long tableId, + String tableImageUrl + ) {} + + @Builder + public record DeleteTableImageDto( + Long tableId + ) {} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index ec01cf2..0de743c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -85,4 +85,14 @@ public void updateSeatCount(int minSeatCount, int maxSeatCount) { public void updateSeatsType(SeatsType seatsType) { this.seatsType = seatsType; } + + // 테이블 이미지 업로드 + public void updateTableImage(String imageKey) { + this.tableImageUrl = imageKey; + } + + // 테이블 이미지 삭제 + public void deleteTableImage() { + this.tableImageUrl = null; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java index 515b791..2f3d8ad 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandService.java @@ -2,6 +2,7 @@ import com.eatsfine.eatsfine.domain.storetable.dto.req.StoreTableReqDto; import com.eatsfine.eatsfine.domain.storetable.dto.res.StoreTableResDto; +import org.springframework.web.multipart.MultipartFile; public interface StoreTableCommandService { StoreTableResDto.TableCreateDto createTable(Long storeId, StoreTableReqDto.TableCreateDto dto); @@ -9,4 +10,8 @@ public interface StoreTableCommandService { StoreTableResDto.TableUpdateResultDto updateTable(Long storeId, Long tableId, StoreTableReqDto.TableUpdateDto dto); StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId); + + StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage); + + StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java index 12c6b77..6117500 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableCommandServiceImpl.java @@ -1,6 +1,8 @@ package com.eatsfine.eatsfine.domain.storetable.service; import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; import com.eatsfine.eatsfine.domain.store.exception.StoreException; import com.eatsfine.eatsfine.domain.store.repository.StoreRepository; import com.eatsfine.eatsfine.domain.store.status.StoreErrorStatus; @@ -16,9 +18,11 @@ import com.eatsfine.eatsfine.domain.table_layout.exception.TableLayoutException; import com.eatsfine.eatsfine.domain.table_layout.exception.status.TableLayoutErrorStatus; import com.eatsfine.eatsfine.domain.table_layout.repository.TableLayoutRepository; +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.math.BigDecimal; import java.time.LocalDate; @@ -35,6 +39,7 @@ public class StoreTableCommandServiceImpl implements StoreTableCommandService { private final TableLayoutRepository tableLayoutRepository; private final StoreTableRepository storeTableRepository; private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 생성 @Override @@ -160,6 +165,58 @@ public StoreTableResDto.TableDeleteDto deleteTable(Long storeId, Long tableId) { return StoreTableConverter.toTableDeleteDto(table); } + // 테이블 이미지 업로드 + @Override + public StoreTableResDto.UploadTableImageDto uploadTableImage(Long storeId, Long tableId, MultipartFile tableImage) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + if (tableImage == null || tableImage.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 기존 이미지가 존재할 경우 삭제 + if (table.getTableImageUrl() != null && !table.getTableImageUrl().isBlank()) { + s3Service.deleteByKey(table.getTableImageUrl()); + } + + String key = s3Service.upload(tableImage, "stores/" + storeId + "/tables/" + tableId); + + table.updateTableImage(key); + + // URL 변환 및 응답 + String tableImageUrl = s3Service.toUrl(key); + + return StoreTableConverter.toUploadTableImageDto(tableId, tableImageUrl); + } + + @Override + public StoreTableResDto.DeleteTableImageDto deleteTableImage(Long storeId, Long tableId) { + storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + StoreTable table = storeTableRepository.findById(tableId) + .orElseThrow(() -> new StoreTableException(StoreTableErrorStatus._TABLE_NOT_FOUND)); + + StoreTableValidator.validateTableBelongsToStore(table, storeId); + + // 이미지가 존재하는지 확인 + if (table.getTableImageUrl() == null || table.getTableImageUrl().isBlank()) { + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + s3Service.deleteByKey(table.getTableImageUrl()); + + table.deleteTableImage(); + + return StoreTableConverter.toDeleteTableImageDto(tableId); + } + private String generateTableNumber(TableLayout layout) { List tables = layout.getTables(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java index d2690dd..99e262b 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/service/StoreTableQueryServiceImpl.java @@ -14,6 +14,7 @@ import com.eatsfine.eatsfine.domain.storetable.validator.StoreTableValidator; import com.eatsfine.eatsfine.domain.tableblock.entity.TableBlock; import com.eatsfine.eatsfine.domain.tableblock.repository.TableBlockRepository; +import com.eatsfine.eatsfine.global.s3.S3Service; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,6 +33,7 @@ public class StoreTableQueryServiceImpl implements StoreTableQueryService{ private final StoreTableRepository storeTableRepository; private final TableBlockRepository tableBlockRepository; private final BookingRepository bookingRepository; + private final S3Service s3Service; // 테이블 슬롯 조회 @Override @@ -57,6 +59,7 @@ public StoreTableResDto.SlotListDto getTableSlots(Long storeId, Long tableId, Lo ); } + // 테이블 상세 조회 @Override public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId, LocalDate targetDate) { storeRepository.findById(storeId) @@ -73,11 +76,15 @@ public StoreTableResDto.TableDetailDto getTableDetail(Long storeId, Long tableId SlotCalculator.SlotCalculationResult result = SlotCalculator.calculateSlots(storeTable, targetDate, tableBlocks, bookedTimes); + // S3 Key -> Url 변환 + String tableImageUrl = s3Service.toUrl(storeTable.getTableImageUrl()); + return StoreTableConverter.toTableDetailDto( storeTable, targetDate, result.totalSlotCount(), - result.availableSlotCount() + result.availableSlotCount(), + tableImageUrl ); } }