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 index f2008156..cfd97401 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -12,7 +12,9 @@ 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", "해당하는 이미지가 존재하지 않습니다.") + _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다."), + _INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "IMAGE4003", "유효하지 않은 이미지 키입니다."), + _INVALID_S3_DIRECTORY(HttpStatus.BAD_REQUEST, "IMAGE4004", "유효하지 않은 S3 디렉토리입니다."), ; @@ -23,7 +25,7 @@ public enum ImageErrorStatus implements BaseErrorCode { @Override public ErrorReasonDto getReason() { return ErrorReasonDto.builder() - .isSuccess(true) + .isSuccess(false) .message(message) .code(code) .build(); @@ -33,7 +35,7 @@ public ErrorReasonDto getReason() { public ErrorReasonDto getReasonHttpStatus() { return ErrorReasonDto.builder() .httpStatus(httpStatus) - .isSuccess(true) + .isSuccess(false) .code(code) .message(message) .build(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java new file mode 100644 index 00000000..cfc3a737 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/controller/MenuController.java @@ -0,0 +1,90 @@ +package com.eatsfine.eatsfine.domain.menu.controller; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.service.MenuCommandService; +import com.eatsfine.eatsfine.domain.menu.service.MenuQueryService; +import com.eatsfine.eatsfine.domain.menu.status.MenuSuccessStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Menu", description = "가게 메뉴 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class MenuController { + + private final MenuCommandService menuCommandService; + private final MenuQueryService menuQueryService; + + @Operation(summary = "메뉴 이미지 선 업로드 API", description = "메뉴 등록 전에 이미지를 먼저 업로드하고 KEY를 반환합니다.") + @PostMapping(value = "/stores/{storeId}/menus/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadImage( + @PathVariable Long storeId, + @RequestPart("image") MultipartFile file + ){ + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_UPLOAD_SUCCESS, menuCommandService.uploadImage(storeId, file)); + } + + @Operation(summary = "메뉴 등록 API", description = "가게의 메뉴들을 등록합니다.") + @PostMapping("/stores/{storeId}/menus") + public ApiResponse createMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuCreateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_CREATE_SUCCESS, menuCommandService.createMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 삭제 API", description = "가게의 메뉴들을 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus") + public ApiResponse deleteMenus( + @PathVariable Long storeId, + @RequestBody @Valid MenuReqDto.MenuDeleteDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_DELETE_SUCCESS, menuCommandService.deleteMenus(storeId, dto)); + } + + @Operation(summary = "메뉴 수정 API", description = "가게의 메뉴를 수정합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}") + public ApiResponse updateMenu( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.MenuUpdateDto dto + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_UPDATE_SUCCESS, menuCommandService.updateMenu(storeId, menuId, dto)); + } + + @Operation(summary = "품절 여부 변경 API", description = "메뉴의 품절 여부를 변경합니다.") + @PatchMapping("/stores/{storeId}/menus/{menuId}/sold-out") + public ApiResponse updateSoldOutStatus( + @PathVariable Long storeId, + @PathVariable Long menuId, + @RequestBody @Valid MenuReqDto.SoldOutUpdateDto dto + ){ + return ApiResponse.of(MenuSuccessStatus._SOLD_OUT_UPDATE_SUCCESS, menuCommandService.updateSoldOutStatus(storeId, menuId, dto.isSoldOut())); + } + + @Operation(summary = "등록된 메뉴 이미지 삭제 API", description = "이미 등록된 메뉴의 이미지를 삭제합니다.") + @DeleteMapping("/stores/{storeId}/menus/{menuId}/image") + public ApiResponse deleteMenuImage( + @PathVariable Long storeId, + @PathVariable Long menuId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_IMAGE_DELETE_SUCCESS, menuCommandService.deleteMenuImage(storeId, menuId)); + } + + @Operation(summary = "메뉴 조회 API", description = "가게의 메뉴들을 조회합니다.") + @GetMapping("/stores/{storeId}/menus") + public ApiResponse getMenus( + @PathVariable Long storeId + ) { + return ApiResponse.of(MenuSuccessStatus._MENU_LIST_SUCCESS, menuQueryService.getMenus(storeId)); + + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java new file mode 100644 index 00000000..702c318b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/converter/MenuConverter.java @@ -0,0 +1,56 @@ +package com.eatsfine.eatsfine.domain.menu.converter; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; + +import java.util.List; + +public class MenuConverter { + + + public static MenuResDto.ImageUploadDto toImageUploadDto(String imageKey, String imageUrl){ + return MenuResDto.ImageUploadDto.builder() + .imageKey(imageKey) + .imageUrl(imageUrl) + .build(); + } + + public static MenuResDto.ImageDeleteDto toImageDeleteDto(String imageKey) { + return MenuResDto.ImageDeleteDto.builder() + .deletedImageKey(imageKey) + .build(); + } + + + public static MenuResDto.MenuCreateDto toCreateDto(List menuDtos) { + return MenuResDto.MenuCreateDto.builder() + .menus(menuDtos) + .build(); + } + + public static MenuResDto.MenuDeleteDto toDeleteDto(List menuIds){ + return MenuResDto.MenuDeleteDto.builder() + .deletedMenuIds(menuIds) + .build(); + } + + public static MenuResDto.MenuUpdateDto toUpdateDto(Menu menu, String updatedImageUrl){ + return MenuResDto.MenuUpdateDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(updatedImageUrl) + .build(); + + } + + public static MenuResDto.SoldOutUpdateDto toSoldOutUpdateDto(Menu menu){ + return MenuResDto.SoldOutUpdateDto.builder() + .menuId(menu.getId()) + .isSoldOut(menu.isSoldOut()) + .build(); + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java new file mode 100644 index 00000000..bd733fad --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuReqDto.java @@ -0,0 +1,59 @@ +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuReqDto { + + public record MenuCreateDto( + @Valid + @NotNull + @Size(min = 1, message = "최소 1개 이상의 메뉴를 등록해야 합니다.") + List menus + ){} + + + public record MenuDto( + @NotBlank(message = "메뉴 이름은 필수입니다.") + String name, + + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + + @NotNull(message = "가격은 필수입니다.") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + + @NotNull(message = "카테고리는 필수입니다.") + MenuCategory category, + + String imageKey // 이미지는 선택 사항이므로 검증 없음 (nullable) + ){} + + + public record MenuDeleteDto( + @NotNull + @Size(min = 1, message = "삭제할 메뉴를 최소 1개 이상 선택해주세요.") + List menuIds + ){} + + public record MenuUpdateDto( + @Size(min = 1, message = "메뉴 이름은 1글자 이상이어야 합니다.") + String name, + @Size(max = 500, message = "설명은 500자 이내여야 합니다.") + String description, + @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") + BigDecimal price, + MenuCategory category, + String imageKey + ){} + + public record SoldOutUpdateDto( + @NotNull + Boolean isSoldOut + ){} +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java new file mode 100644 index 00000000..0a8b419c --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/dto/MenuResDto.java @@ -0,0 +1,75 @@ + +package com.eatsfine.eatsfine.domain.menu.dto; + +import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; +import lombok.Builder; + +import java.math.BigDecimal; +import java.util.List; + +public class MenuResDto { + + @Builder + public record ImageUploadDto( + String imageKey, // 메뉴 등록/수정 시 서버에 다시 보낼 키 + String imageUrl // 프론트엔드에서 즉시 미리보기를 위한 전체 URL + ){} + + + @Builder + public record ImageDeleteDto( + String deletedImageKey + ){} + + @Builder + public record MenuCreateDto( + List menus + ){} + + @Builder + public record MenuDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record MenuDeleteDto( + List deletedMenuIds + ){} + + @Builder + public record MenuUpdateDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl + ){} + + @Builder + public record SoldOutUpdateDto( + Long menuId, + boolean isSoldOut + ){} + + @Builder + public record MenuListDto( + List menus + ){} + + @Builder + public record MenuDetailDto( + Long menuId, + String name, + String description, + BigDecimal price, + MenuCategory category, + String imageUrl, + boolean isSoldOut + ){} +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java index 6a3f3170..83119ee0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java @@ -5,8 +5,12 @@ import com.eatsfine.eatsfine.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.annotations.Where; import java.math.BigDecimal; +import java.time.LocalDateTime; @Entity @Getter @@ -14,6 +18,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Table(name = "menu") +@SQLDelete(sql = "UPDATE menu SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") public class Menu extends BaseEntity { @Id @@ -44,6 +50,8 @@ public class Menu extends BaseEntity { @Column(name = "is_sold_out", nullable = false) private boolean isSoldOut = false; + private LocalDateTime deletedAt; + public void assignStore(Store store) { this.store = store; } @@ -58,4 +66,21 @@ public void updateImageKey(String imageKey) { this.imageKey = imageKey; } + // --- 메뉴 정보 수정 메서드 --- + + public void updateName(String name) { + this.name = name; + } + + public void updateDescription(String description) { + this.description = description; + } + + public void updatePrice(BigDecimal price) { + this.price = price; + } + + public void updateCategory(MenuCategory menuCategory) { + this.menuCategory = menuCategory; + } } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java new file mode 100644 index 00000000..c84882ff --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/exception/MenuException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.menu.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class MenuException extends GeneralException { + public MenuException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java index b0335d22..80c6562c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/repository/MenuRepository.java @@ -3,5 +3,8 @@ import com.eatsfine.eatsfine.domain.menu.entity.Menu; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface MenuRepository extends JpaRepository { + Optional findByImageKey(String imageKey); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java new file mode 100644 index 00000000..862e407b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandService.java @@ -0,0 +1,15 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import org.springframework.web.multipart.MultipartFile; + +public interface MenuCommandService { + MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file); + MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId); + MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto menuCreateDto); + MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto menuDeleteDto); + MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto menuUpdateDto); + MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut); + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java new file mode 100644 index 00000000..9685c386 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuCommandServiceImpl.java @@ -0,0 +1,278 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.menu.converter.MenuConverter; +import com.eatsfine.eatsfine.domain.menu.dto.MenuReqDto; +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.menu.entity.Menu; +import com.eatsfine.eatsfine.domain.menu.exception.MenuException; +import com.eatsfine.eatsfine.domain.menu.repository.MenuRepository; +import com.eatsfine.eatsfine.domain.menu.status.MenuErrorStatus; +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.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.Transaction; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +@Slf4j +public class MenuCommandServiceImpl implements MenuCommandService { + + private final S3Service s3Service; + private final StoreRepository storeRepository; + private final MenuRepository menuRepository; + + @Override + public MenuResDto.MenuCreateDto createMenus(Long storeId, MenuReqDto.MenuCreateDto dto) { + Store store = findAndVerifyStore(storeId); + + List menus = dto.menus().stream() + .map(menuDto -> { + Menu menu = Menu.builder() + .name(menuDto.name()) + .description(menuDto.description()) + .price(menuDto.price()) + .menuCategory(menuDto.category()) + .build(); + + // 임시 이미지 키가 있는 경우, 영구 경로로 이동하고 키를 설정 + String tempImageKey = menuDto.imageKey(); + if (tempImageKey != null && !tempImageKey.isBlank()) { + // 1. 새로운 영구 키 생성 + String extension = s3Service.extractExtension(tempImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + + // 2. S3에서 객체 이동 (임시 -> 영구) + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit(){ + try{ + s3Service.moveObject(tempImageKey, permanentImageKey); + } catch (Exception e) { + log.error("temp에서 영구로 이동 실패. Source: {}, Dest: {}", tempImageKey, permanentImageKey); + } + } + + }); + + // 3. 엔티티에 영구 키 저장 + menu.updateImageKey(permanentImageKey); + } + + store.addMenu(menu); + return menu; + }) + .toList(); + + List savedMenus = menuRepository.saveAll(menus); + + List menuDtos = savedMenus.stream().map( + menu -> MenuResDto.MenuDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .build()) + .toList(); + + return MenuConverter.toCreateDto(menuDtos); + } + + @Override + public MenuResDto.MenuDeleteDto deleteMenus(Long storeId, MenuReqDto.MenuDeleteDto dto) { + Store store = findAndVerifyStore(storeId); + + List menuIds = dto.menuIds(); + List menusToDelete = menuRepository.findAllById(dto.menuIds()); + + if(menusToDelete.size() != menuIds.size()) { + throw new MenuException(MenuErrorStatus._MENU_NOT_FOUND); + } + + // 1. 모든 메뉴가 해당 가게 소유인지 확인하고, S3 이미지 삭제 + menusToDelete.forEach(menu -> { + verifyMenuBelongsToStore(menu, storeId); + // Soft Delete 시 연결된 S3 이미지도 함께 삭제 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String imageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + } + }); + + // 2. DB에서 Soft Delete 실행 + // Menu 엔티티의 @SQLDelete 어노테이션 덕분에 deleteAll이 UPDATE로 동작함 + menuRepository.deleteAll(menusToDelete); + + // 3. Store 컬렉션에서 제거 + store.getMenus().removeAll(menusToDelete); + + return MenuConverter.toDeleteDto(menuIds); + } + + @Override + public MenuResDto.MenuUpdateDto updateMenu(Long storeId, Long menuId, MenuReqDto.MenuUpdateDto dto) { + Store store = findAndVerifyStore(storeId); + + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 이름, 설명, 가격, 카테고리 업데이트 + Optional.ofNullable(dto.name()).ifPresent(menu::updateName); + Optional.ofNullable(dto.description()).ifPresent(menu::updateDescription); + Optional.ofNullable(dto.price()).ifPresent(menu::updatePrice); + Optional.ofNullable(dto.category()).ifPresent(menu::updateCategory); + + Optional.ofNullable(dto.imageKey()).ifPresent(newImageKey -> { + // 1. [Safety] 변경된 내용이 없으면 스킵 (프론트에서 기존 키를 그대로 보낸 경우) + if (newImageKey.equals(menu.getImageKey())) { + return; + } + + // 새로운 이미지가 있다면 영구 경로로 이동 (Temp -> Perm) + if (newImageKey != null && !newImageKey.isBlank()) { + String extension = s3Service.extractExtension(newImageKey); + String permanentImageKey = "stores/" + storeId + "/menus/" + UUID.randomUUID() + extension; + String oldImageKey = menu.getImageKey(); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + s3Service.moveObject(newImageKey, permanentImageKey); + if (oldImageKey != null && !oldImageKey.isBlank()) { + s3Service.deleteByKey(oldImageKey); + } + } + catch (Exception e) { + log.error("메뉴 이미지를 s3에 업데이트하는 데에 실패했습니다.", e); + } + } + }); + + menu.updateImageKey(permanentImageKey); + + } else { + // 빈 문자열("")인 경우 -> 이미지 삭제 요청 + if (menu.getImageKey() != null && !menu.getImageKey().isBlank()) { + String oldImageKey = menu.getImageKey(); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(oldImageKey); + } + }); + } + menu.updateImageKey(null); + } + }); + + String updatedImageUrl = s3Service.toUrl(menu.getImageKey()); + + return MenuConverter.toUpdateDto(menu, updatedImageUrl); + } + + @Override + public MenuResDto.SoldOutUpdateDto updateSoldOutStatus(Long storeId, Long menuId, boolean isSoldOut) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + // 기존 값과 동일하다면 바로 리턴 + if(menu.isSoldOut() == isSoldOut) { + return MenuConverter.toSoldOutUpdateDto(menu); + } + + menu.updateSoldOut(isSoldOut); + + return MenuConverter.toSoldOutUpdateDto(menu); + + } + + @Override + public MenuResDto.ImageUploadDto uploadImage(Long storeId, MultipartFile file) { + Store store = findAndVerifyStore(storeId); + + if(file.isEmpty()) { + throw new ImageException(ImageErrorStatus.EMPTY_FILE); + } + + // 이미지를 항상 임시 경로에 업로드 + String tempPath = "temp/menus"; + String imageKey = s3Service.upload(file, tempPath); + + return MenuConverter.toImageUploadDto(imageKey, s3Service.toUrl(imageKey)); + } + + @Override + public MenuResDto.ImageDeleteDto deleteMenuImage(Long storeId, Long menuId) { + findAndVerifyStore(storeId); + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new MenuException(MenuErrorStatus._MENU_NOT_FOUND)); + + verifyMenuBelongsToStore(menu, storeId); + + String imageKey = menu.getImageKey(); + + if (imageKey == null || imageKey.isBlank()) { + // 이미지가 없는 메뉴에 삭제 요청이 온 경우, 예외 + throw new ImageException(ImageErrorStatus._IMAGE_NOT_FOUND); + } + + // 1. S3에서 파일 삭제 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + s3Service.deleteByKey(imageKey); + } + }); + + // 2. DB에서 imageKey를 null로 업데이트 (Dirty Checking) + menu.updateImageKey(null); + + return MenuConverter.toImageDeleteDto(imageKey); // 삭제된 이미지의 키를 반환 + } + + private Store findAndVerifyStore(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + // TODO: [보안] Spring Security 병합 후, 현재 로그인한 사용자가 이 가게의 주인인지 확인하는 로직 추가 + return store; + } + + private void verifyMenuBelongsToStore(Menu menu, Long storeId) { + if (!menu.getStore().getId().equals(storeId)) { + // 다른 가게의 메뉴를 조작하려는 시도 방지 + throw new StoreException(StoreErrorStatus._STORE_NOT_OWNER); + } + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java new file mode 100644 index 00000000..f5c80cb7 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryService.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; + +public interface MenuQueryService { + MenuResDto.MenuListDto getMenus(Long storeId); +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java new file mode 100644 index 00000000..ddb0e546 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/service/MenuQueryServiceImpl.java @@ -0,0 +1,45 @@ +package com.eatsfine.eatsfine.domain.menu.service; + +import com.eatsfine.eatsfine.domain.menu.dto.MenuResDto; +import com.eatsfine.eatsfine.domain.store.dto.StoreResDto; +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.global.s3.S3Service; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuQueryServiceImpl implements MenuQueryService { + private final StoreRepository storeRepository; + private final S3Service s3Service; + + @Override + public MenuResDto.MenuListDto getMenus(Long storeId) { + Store store = storeRepository.findByIdWithMenus(storeId) + .orElseThrow(() -> new StoreException(StoreErrorStatus._STORE_NOT_FOUND)); + + List menuDtos = store.getMenus().stream() + .map(menu -> MenuResDto.MenuDetailDto.builder() + .menuId(menu.getId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .category(menu.getMenuCategory()) + .imageUrl(s3Service.toUrl(menu.getImageKey())) + .isSoldOut(menu.isSoldOut()) + .build() + ) + .toList(); + + return MenuResDto.MenuListDto.builder() + .menus(menuDtos) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java new file mode 100644 index 00000000..b467af0a --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuErrorStatus.java @@ -0,0 +1,40 @@ +package com.eatsfine.eatsfine.domain.menu.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 MenuErrorStatus implements BaseErrorCode { + + _MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "MENU404", "메뉴를 찾을 수 없습니다."), + + ; + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .isSuccess(false) + .httpStatus(httpStatus) + .message(message) + .code(code) + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java new file mode 100644 index 00000000..1a696bef --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/status/MenuSuccessStatus.java @@ -0,0 +1,50 @@ +package com.eatsfine.eatsfine.domain.menu.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 MenuSuccessStatus implements BaseCode { + + + _MENU_IMAGE_UPLOAD_SUCCESS(HttpStatus.CREATED, "MENU201", "메뉴 이미지 업로드 성공"), + + _MENU_IMAGE_DELETE_SUCCESS(HttpStatus.OK, "MENU200", "메뉴 이미지 삭제 성공"), + + + _MENU_CREATE_SUCCESS(HttpStatus.CREATED, "MENU202", "메뉴 생성 성공"), + _MENU_DELETE_SUCCESS(HttpStatus.OK, "MENU2002", "메뉴 삭제 성공"), + _MENU_UPDATE_SUCCESS(HttpStatus.OK, "MENU2003", "메뉴 수정 성공"), + _SOLD_OUT_UPDATE_SUCCESS(HttpStatus.OK, "MENU2005", "품절 여부 변경 성공"), + _MENU_LIST_SUCCESS(HttpStatus.OK, "MENU2004", "메뉴 조회 성공"), + + ; + + + 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/domain/store/converter/StoreConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/store/converter/StoreConverter.java index 962c97aa..6166ca25 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 @@ -48,7 +48,6 @@ public static StoreResDto.StoreDetailDto toDetailDto(Store store, boolean isOpen .reviewCount(null) // reviewCount는 추후 리뷰 로직 구현 시 추가 예정 .mainImageUrl(store.getMainImageKey()) .tableImageUrls(Collections.emptyList()) // tableImages는 추후 사진 등록 API 구현 시 추가 예정 - .depositAmount(store.calculateDepositAmount()) .businessHours( store.getBusinessHours().stream() .map(BusinessHoursConverter::toSummary) diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java index 798dad94..70e513c4 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/dto/StoreReqDto.java @@ -53,9 +53,6 @@ public record StoreCreateDto( @NotNull(message = "카테고리는 필수입니다.") Category category, - @NotNull(message = "최소 메뉴 가격은 필수입니다.") - int minPrice, - @NotNull(message = "예약금 비율은 필수입니다.") DepositRate depositRate, @@ -79,8 +76,6 @@ public record StoreUpdateDto( Category category, - Integer minPrice, - DepositRate depositRate, Integer bookingIntervalMinutes 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 c9ee44a2..8431ff63 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 @@ -54,7 +54,6 @@ public record StoreDetailDto( Category category, BigDecimal rating, Long reviewCount, - BigDecimal depositAmount, String mainImageUrl, List tableImageUrls, List businessHours, 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 286fa6c0..1d56603d 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 @@ -82,9 +82,6 @@ public class Store extends BaseEntity { @Column(name = "booking_interval_minutes", nullable = false) private int bookingIntervalMinutes = 30; - @Column(name = "min_price", nullable = false) - private int minPrice; - @Enumerated(EnumType.STRING) @Column(name = "deposit_rate", nullable = false) private DepositRate depositRate; @@ -95,7 +92,7 @@ public class Store extends BaseEntity { @Builder.Default @BatchSize(size = 100) - @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "store", cascade = CascadeType.ALL) private List menus = new ArrayList<>(); @@ -134,14 +131,6 @@ public void addMenu(Menu menu) { menu.assignStore(this); } - // 메뉴 삭제 - public void removeMenu(Menu menu) { - this.menus.remove(menu); - menu.assignStore(null); - } - - - public void addTableImage(TableImage tableImage) { this.tableImages.add(tableImage); tableImage.assignStore(this); @@ -173,14 +162,6 @@ public Optional findBusinessHoursByDay(DayOfWeek dayOfWeek) { .findFirst(); } - // 예약금 계산 메서드 - public BigDecimal calculateDepositAmount() { - return BigDecimal.valueOf(minPrice) - .multiply(BigDecimal.valueOf(depositRate.getPercent())) - .divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN); - } - - // StoreTable에 대한 연관관계 편의 메서드는 추후 추가 예정 // 가게 기본 정보 변경 메서드 public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { @@ -200,9 +181,6 @@ public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { this.category = dto.category(); } - if(dto.minPrice() != null) { - this.minPrice = dto.minPrice(); - } if(dto.depositRate() != null) { this.depositRate = dto.depositRate(); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java index 7c72fd36..6cd3f595 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/repository/StoreRepository.java @@ -2,7 +2,21 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface StoreRepository extends JpaRepository, StoreRepositoryCustom { + + @Query(""" + select s from Store s + left join fetch s.menus m + where s.id = :id + and (m.deletedAt IS NULL or m.id IS NULL) + +""") + Optional findByIdWithMenus(@Param("id") Long id); + } \ No newline at end of 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 d8c4c340..e9054cc4 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 @@ -67,7 +67,6 @@ public StoreResDto.StoreCreateDto createStore(StoreReqDto.StoreCreateDto dto) { .phoneNumber(dto.phoneNumber()) .category(dto.category()) .bookingIntervalMinutes(dto.bookingIntervalMinutes()) - .minPrice(dto.minPrice()) .depositRate(dto.depositRate()) .build(); @@ -101,7 +100,6 @@ public List extractUpdatedFields(StoreReqDto.StoreUpdateDto dto) { 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"); diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java index b52dda24..0e0f4557 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/status/StoreErrorStatus.java @@ -13,7 +13,10 @@ public enum StoreErrorStatus implements BaseErrorCode { _STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE404", "해당하는 가게를 찾을 수 없습니다."), _STORE_DETAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE_DETAIL404", "가게 상세 정보를 찾을 수 없습니다."), - _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."),; + _STORE_NOT_OPEN_ON_DAY(HttpStatus.NOT_FOUND,"STORE4041" , "해당 영업시간 정보를 찾을 수 없습니다."), + + _STORE_NOT_OWNER(HttpStatus.FORBIDDEN, "STORE403", "해당 가게의 주인이 아닙니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java index 0c01904a..7b2212f4 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -4,11 +4,13 @@ import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import jakarta.validation.ConstraintViolationException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.ServletWebRequest; @@ -34,6 +36,26 @@ public ResponseEntity exception(Exception e, WebRequest request) { return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); } + // @Valid 유효성 검사 실패 시 (RequestBody) + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException e, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request + ) { + String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, headers, status, request, errorMessage); + } + + // @RequestParam, @PathVariable 유효성 검사 실패 시 (ConstraintViolationException) + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + // 첫 번째 에러 메시지만 추출 + String errorMessage = e.getConstraintViolations().iterator().next().getMessage(); + return handleExceptionInternalFalse(e, ErrorStatus._BAD_REQUEST, HttpHeaders.EMPTY, ErrorStatus._BAD_REQUEST.getHttpStatus(), request, errorMessage); + } + // 3. 커스텀 예외용 내부 응답 생성 메서드 private ResponseEntity handleExceptionInternal(Exception e, BaseErrorCode code, HttpHeaders headers, HttpServletRequest request) { // 정의하신 ApiResponse.onFailure(BaseErrorCode code, T result)를 호출합니다. diff --git a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java index 774fc367..d3fdb380 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java +++ b/src/main/java/com/eatsfine/eatsfine/global/s3/S3Service.java @@ -9,6 +9,7 @@ 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.CopyObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; @@ -46,7 +47,9 @@ public String upload(MultipartFile file, String directory) { } public void deleteByKey(String key) { - if (key == null || key.isBlank()) return; + if (key == null || key.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } s3Client.deleteObject(DeleteObjectRequest.builder() .bucket(bucket) @@ -54,6 +57,27 @@ public void deleteByKey(String key) { .build()); } + public void moveObject(String sourceKey, String destinationKey) { + if (sourceKey == null || destinationKey == null || sourceKey.isBlank() || destinationKey.isBlank()) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + if (sourceKey.equals(destinationKey)) { + throw new ImageException(ImageErrorStatus._INVALID_IMAGE_KEY); + } + + // 1. 객체 복사 + s3Client.copyObject(CopyObjectRequest.builder() + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destinationKey) + .build()); + + // 2. 원본(임시) 객체 삭제 + deleteByKey(sourceKey); + } + public String toUrl(String key) { if (key == null || key.isBlank()) return null; return baseUrl + "/" + key; @@ -61,13 +85,13 @@ public String toUrl(String key) { private String generateKey(MultipartFile file, String directory) { if(directory == null || directory.isBlank()) { - throw new IllegalArgumentException("S3 디렉토리는 비어있을 수 없습니다."); + throw new ImageException(ImageErrorStatus._INVALID_S3_DIRECTORY); } String extension = extractExtension(file.getOriginalFilename()); return directory + "/" + UUID.randomUUID() + extension; } - private String extractExtension(String filename) { + public String extractExtension(String filename) { // public으로 변경 if (filename == null || !filename.contains(".")) { throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); }