diff --git a/build.gradle b/build.gradle index fb9ee7c..03ca3ee 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,9 @@ dependencies { // JavaNetCookieJar implementation 'com.squareup.okhttp3:okhttp-urlconnection:4.9.3' + + // S3 + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.4.2' } tasks.named('test') { diff --git a/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java b/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java index 291ec2c..ddc4f8a 100644 --- a/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java +++ b/src/main/java/com/campustable/be/domain/menu/controller/MenuController.java @@ -8,13 +8,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; +import org.springframework.web.multipart.MultipartFile; @RestController -@RequestMapping("/api/menu") +@RequestMapping("/api") @RequiredArgsConstructor public class MenuController implements MenuControllerDocs { @@ -23,7 +25,7 @@ public class MenuController implements MenuControllerDocs { @Override - @GetMapping + @GetMapping("/menus") @LogMonitoringInvocation public ResponseEntity> getAllMenus(){ @@ -35,7 +37,7 @@ public ResponseEntity> getAllMenus(){ @Override @LogMonitoringInvocation - @GetMapping("/category/{category_id}") + @GetMapping("/category/{category_id}/menus") public ResponseEntity> getAllMenusByCategoryId( @PathVariable(name = "category_id") Long categoryId){ @@ -47,14 +49,14 @@ public ResponseEntity> getAllMenusByCategoryId( @Override @LogMonitoringInvocation - @GetMapping("/{menuId}") + @GetMapping("/menus/{menuId}") public ResponseEntity getMenuById(@PathVariable Long menuId){ return ResponseEntity.ok(menuService.getMenuById(menuId)); } @Override @LogMonitoringInvocation - @GetMapping("/cafeteria/{cafeteria-id}") + @GetMapping("/menus/cafeteria/{cafeteria-id}") public ResponseEntity> getAllMenusByCafeteriaId( @PathVariable(name = "cafeteria-id") Long cafeteriaId ) { @@ -62,16 +64,31 @@ public ResponseEntity> getAllMenusByCafeteriaId( } @Override - @PostMapping + @PostMapping(value = "/admin/menus", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @LogMonitoringInvocation - public ResponseEntity createMenu(@Valid @RequestBody MenuRequest createRequest){ - MenuResponse createMenu = menuService.createMenu(createRequest); + public ResponseEntity createMenu( + @Valid @ModelAttribute MenuRequest request + ){ + MenuResponse createMenu = menuService.createMenu(request, request.getImage()); return ResponseEntity.status(HttpStatus.CREATED).body(createMenu); } @Override - @PatchMapping("/{menu_id}") + @PostMapping(value = "/admin/menus/{menu_id}/image" ,consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @LogMonitoringInvocation + public ResponseEntity uploadMenuImage( + @PathVariable(name = "menu_id") Long menuId, + @RequestParam("image") MultipartFile image){ + + MenuResponse response = menuService.uploadMenuImage(menuId, image); + + return ResponseEntity.ok(response); + + } + + @Override + @PatchMapping("/admin/menus/{menu_id}") @LogMonitoringInvocation public ResponseEntity updateMenu( @PathVariable(name = "menu_id") Long menuId, @@ -84,7 +101,7 @@ public ResponseEntity updateMenu( @Override @LogMonitoringInvocation - @DeleteMapping("/{menu_id}") + @DeleteMapping("/admin/menus/{menu_id}") public ResponseEntity deleteMenu( @PathVariable(name = "menu_id") Long menuId) { diff --git a/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java b/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java index a1d3479..35b88ff 100644 --- a/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java +++ b/src/main/java/com/campustable/be/domain/menu/controller/MenuControllerDocs.java @@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity; import java.util.List; +import org.springframework.web.multipart.MultipartFile; /** * 메뉴 관리 시스템의 API 명세를 정의하는 인터페이스입니다. @@ -24,7 +25,6 @@ public interface MenuControllerDocs { /** * 시스템에 등록된 모든 메뉴 목록을 조회합니다. - * * @return 메뉴 정보 리스트를 담은 ResponseEntity */ @Operation(summary = "메뉴 전체 조회", description = "모든 메뉴 목록을 조회합니다.") @ApiResponse(responseCode = "200", description = "조회 성공") @@ -32,8 +32,6 @@ public interface MenuControllerDocs { /** * 고유 식별자를 통해 단일 메뉴의 상세 정보를 조회합니다. - * * @param menuId 조회하고자 하는 메뉴의 ID - * @return 해당 메뉴의 상세 정보를 담은 ResponseEntity */ @Operation(summary = "단일 메뉴 상세 조회", description = "특정 ID에 해당하는 메뉴의 상세 정보를 조회합니다.") @ApiResponses({ @@ -47,8 +45,6 @@ ResponseEntity getMenuById( /** * 특정 카테고리에 속한 모든 메뉴를 조회합니다. - * * @param categoryId 카테고리 고유 식별자 - * @return 해당 카테고리의 메뉴 리스트를 담은 ResponseEntity */ @Operation(summary = "카테고리별 메뉴 조회", description = "특정 카테고리 ID에 해당하는 메뉴 목록을 조회합니다.") @ApiResponses({ @@ -62,8 +58,6 @@ ResponseEntity> getAllMenusByCategoryId( /** * 특정 식당에서 제공하는 모든 메뉴를 조회합니다. - * * @param cafeteriaId 식당 고유 식별자 - * @return 해당 식당의 메뉴 리스트를 담은 ResponseEntity */ @Operation(summary = "식당별 메뉴 조회", description = "식당 ID에 해당하는 메뉴 목록을 조회합니다.") @ApiResponses({ @@ -77,7 +71,7 @@ ResponseEntity> getAllMenusByCafeteriaId( /** * 새로운 메뉴를 시스템에 등록합니다. (관리자 권한 필요) - * * @param menuRequest 생성할 메뉴의 상세 정보 DTO + * * @param request 생성할 메뉴의 상세 정보 DTO + 이미지 파일 * @return 생성된 메뉴 정보를 담은 ResponseEntity */ @Operation(summary = "신규 메뉴 생성 (관리자 전용)", description = "새로운 메뉴를 등록합니다.") @@ -88,15 +82,36 @@ ResponseEntity> getAllMenusByCafeteriaId( @ApiResponse(responseCode = "409", description = "이미 존재하는 메뉴입니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) - ResponseEntity createMenu(MenuRequest menuRequest); + ResponseEntity createMenu( + @Parameter(description = "메뉴 정보 및 이미지 파일") MenuRequest request + ); + + /** + * 메뉴에 이미지를 업로드 합니다. (관리자 권한 필요) + * * @param menuId 이미지를 등록할 메뉴의 ID + * @param image 업로드할 이미지 파일 + * @return 이미지 업로드가 완료된 메뉴 정보를 담은 ResponseEntity + */ + @Operation( + summary = "메뉴 이미지 개별 업로드/수정 (관리자 전용)", + description = "메뉴의 사진을 추가하거나 수정합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "이미지 업로드 및 경로 업데이트 성공"), + @ApiResponse(responseCode = "404", description = "해당 ID의 메뉴를 찾을 수 없습니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "400", description = "유효하지 않은 파일 요청입니다.", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + ResponseEntity uploadMenuImage( + @Parameter(description = "대상 메뉴 ID", example = "1") Long menuId, + @Parameter(description = "업로드할 이미지 파일") MultipartFile image + ); /** * 기존 메뉴 정보를 수정합니다. (관리자 권한 필요) - * * @param menuId 수정할 메뉴의 ID - * @param menuUpdateRequest 수정할 내용이 담긴 DTO - * @return 수정 완료된 메뉴 정보를 담은 ResponseEntity */ - @Operation(summary = "메뉴 정보 수정 (관리자 전용)", description = "특정 ID의 메뉴 정보를 수정합니다.") + @Operation(summary = "메뉴 정보 수정 (관리자 전용)", description = "특정 ID의 메뉴 정보를 수정합니다. (이미지 제외)") @ApiResponses({ @ApiResponse(responseCode = "200", description = "메뉴 수정 성공"), @ApiResponse(responseCode = "400", description = "입력값 오류", @@ -106,13 +121,11 @@ ResponseEntity> getAllMenusByCafeteriaId( }) ResponseEntity updateMenu( @Parameter(description = "수정할 메뉴 ID", example = "1") Long menuId, - MenuUpdateRequest menuUpdateRequest + @Parameter(description = "수정할 메뉴 정보") MenuUpdateRequest menuUpdateRequest ); /** * 특정 메뉴를 시스템에서 삭제합니다. (관리자 권한 필요) - * * @param menuId 삭제할 메뉴의 ID - * @return 삭제 성공 시 빈 바디를 담은 ResponseEntity (204 No Content) */ @Operation(summary = "메뉴 삭제 (관리자 전용)", description = "특정 ID의 메뉴를 삭제합니다.") @ApiResponses({ diff --git a/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java b/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java index 5f42a07..9858225 100644 --- a/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java +++ b/src/main/java/com/campustable/be/domain/menu/dto/MenuRequest.java @@ -10,6 +10,7 @@ import java.math.BigDecimal; import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; @Getter @Setter @@ -26,21 +27,21 @@ public class MenuRequest { @Min(value = 0, message = "가격은 0원 이상이어야 합니다.") private Integer price; - @NotBlank(message = "이미지를 위한url은 필수입니다.") - private String menuUrl; @NotNull(message = "판매 가능 여부는 필수입니다.") private Boolean available; private Integer stockQuantity; + private MultipartFile image; + public Menu toEntity(Category category) { return Menu.builder() .category(category) .menuName(this.getMenuName()) .price(this.getPrice()) - .menuUrl(this.getMenuUrl()) + .menuUrl(null) .available(this.getAvailable()) .stockQuantity(this.getStockQuantity()) .build(); diff --git a/src/main/java/com/campustable/be/domain/menu/service/MenuService.java b/src/main/java/com/campustable/be/domain/menu/service/MenuService.java index 146c24e..04f1ba1 100644 --- a/src/main/java/com/campustable/be/domain/menu/service/MenuService.java +++ b/src/main/java/com/campustable/be/domain/menu/service/MenuService.java @@ -10,6 +10,7 @@ import com.campustable.be.domain.menu.dto.MenuUpdateRequest; import com.campustable.be.domain.menu.entity.Menu; import com.campustable.be.domain.menu.repository.MenuRepository; +import com.campustable.be.domain.s3.service.S3Service; import com.campustable.be.global.exception.CustomException; import com.campustable.be.global.exception.ErrorCode; import java.util.ArrayList; @@ -19,6 +20,7 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Optional; +import org.springframework.web.multipart.MultipartFile; @Slf4j @@ -29,10 +31,11 @@ public class MenuService { private final MenuRepository menuRepository; private final CategoryRepository categoryRepository; private final CafeteriaService cafeteriaService; + private final S3Service s3Service; @Transactional - public MenuResponse createMenu(MenuRequest request) { + public MenuResponse createMenu(MenuRequest request, MultipartFile image) { Category category = categoryRepository.findById(request.getCategoryId()) .orElseThrow(() -> { @@ -40,26 +43,67 @@ public MenuResponse createMenu(MenuRequest request) { return new CustomException(ErrorCode.CATEGORY_NOT_FOUND); }); - Optional existingMenu = menuRepository.findByCategoryAndMenuName( - category, - request.getMenuName() - ); + menuRepository.findByCategoryAndMenuName(category, request.getMenuName()) + .ifPresent(menu -> { + log.error("createMenu: 이미 해당 카테고리에 menu가 존재합니다. 생성이 아닌 수정을 통해 진행해주세요."); + throw new CustomException(ErrorCode.MENU_ALREADY_EXISTS); + }); + + Menu menu = request.toEntity(category); + Menu savedMenu = menuRepository.save(menu); - if (existingMenu.isPresent()) { - log.error("createMenu: 이미 해당 카테고리에 menu가 존재합니다. 생성이 아닌 수정을 통해 진행해주세요."); - throw new CustomException(ErrorCode.MENU_ALREADY_EXISTS); + if (image != null && !image.isEmpty()) { + return uploadMenuImage(savedMenu.getId(), image); } - Menu menu = request.toEntity(category); - return MenuResponse.from(menuRepository.save(menu)); + return MenuResponse.from(savedMenu); + + } + + @Transactional + public MenuResponse uploadMenuImage(Long menuId, MultipartFile image) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new CustomException(ErrorCode.MENU_NOT_FOUND)); + + if (image == null || image.isEmpty()) { + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + + String oldUrl = menu.getMenuUrl(); + String cafeteriaName = menu.getCategory().getCafeteria().getName(); + String dirName = "menu/" + cafeteriaName; + + String newUrl = s3Service.uploadFile(image, dirName); + menu.setMenuUrl(newUrl); + Menu savedMenu; + try { + savedMenu = menuRepository.save(menu); + } catch (Exception e) { + try { + s3Service.deleteFile(newUrl); + } catch (Exception ex) { + log.warn("uploadMenuImage: 신규 이미지 정리 실패. newUrl={}", newUrl, ex); + } + log.error("uploadMenuImage: 메뉴 저장 실패. menuId={}", menuId, e); + throw e; + } + + if (oldUrl != null && !oldUrl.isBlank()) { + try { + s3Service.deleteFile(oldUrl); + } catch (Exception e) { + log.warn("uploadMenuImage: 기존 이미지 삭제 실패. oldUrl={}", oldUrl, e); + } + } + return MenuResponse.from(savedMenu); } @Transactional(readOnly = true) public MenuResponse getMenuById(Long menuId) { Menu menu = menuRepository.findById(menuId) - .orElseThrow(()->{ + .orElseThrow(() -> { log.error("getMenuById : 유효하지않은 menuId"); return new CustomException(ErrorCode.MENU_NOT_FOUND); }); @@ -137,9 +181,15 @@ public void deleteMenu(Long menuId) { if (menu.isEmpty()) { log.error("menuId not found {}", menuId); throw new CustomException(ErrorCode.MENU_NOT_FOUND); - } else { - menuRepository.delete(menu.get()); } + if (menu.get().getMenuUrl() != null && !menu.get().getMenuUrl().isBlank()) { + try { + s3Service.deleteFile(menu.get().getMenuUrl()); + } catch (Exception e) { + log.warn("deleteMenu: 이미지 삭제 실패. menuId={}, url={}", menuId, menu.get().getMenuUrl(), e); + } + } + menuRepository.deleteById(menuId); } } diff --git a/src/main/java/com/campustable/be/domain/s3/service/S3Service.java b/src/main/java/com/campustable/be/domain/s3/service/S3Service.java new file mode 100644 index 0000000..6fcad62 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/s3/service/S3Service.java @@ -0,0 +1,117 @@ +package com.campustable.be.domain.s3.service; + +import com.campustable.be.domain.s3.util.FileUtil; +import com.campustable.be.global.common.CommonUtil; +import com.campustable.be.global.exception.CustomException; +import com.campustable.be.global.exception.ErrorCode; +import io.awspring.cloud.s3.S3Exception; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import jakarta.transaction.Transactional; +import java.io.IOException; +import java.io.InputStream; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Slf4j +@RequiredArgsConstructor +public class S3Service { + + private final S3Template s3Template; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + + /** + * 파일 검증 및 원본 파일명 반환 + * + * @param file 요청된 MultipartFile + * @return 원본 파일명 + */ + private static String validateAndExtractFilename(MultipartFile file) { + // 파일 검증 + if (FileUtil.isNullOrEmpty(file)) { + log.error("파일이 비어있거나 존재하지 않습니다."); + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + + // 원본 파일 명 검증 + String originalFilename = file.getOriginalFilename(); + + if (CommonUtil.nvl(originalFilename, "").isEmpty()) { + log.error("원본 파일명이 비어있거나 존재하지 않습니다."); + throw new CustomException(ErrorCode.INVALID_FILE_REQUEST); + } + return originalFilename; + } + + public String uploadFile(MultipartFile file, String dirName) { + + String originalFilename = validateAndExtractFilename(file); + + String storedPath = dirName + "/" + UUID.randomUUID() + "_" + originalFilename; + log.debug("생성된 파일명: {}", storedPath); + + try (InputStream inputStream = file.getInputStream()) { + + S3Resource resource = s3Template.upload(bucket, storedPath, inputStream); + + String s3Url = resource.getURL().toString(); + log.info("S3 업로드 성공: {}", s3Url); + + return s3Url; + + } catch (S3Exception e) { + log.error("AmazonServiceException - S3 파일 업로드 실패. 버킷: {}, 파일명: {}, 에러: {}", bucket, storedPath, e.getMessage()); + throw new CustomException(ErrorCode.S3_UPLOAD_AMAZON_CLIENT_ERROR); + } catch (IOException e) { + log.error("IOException - 파일 스트림 처리 중 에러 발생. 원본 파일명: {}, 파일명: {} 에러: {}", originalFilename, storedPath, e.getMessage()); + throw new CustomException(ErrorCode.S3_UPLOAD_ERROR); + } + + } + + public void deleteFile(String storedPath) { + if (CommonUtil.nvl(storedPath, "").isEmpty()) { + log.warn("요청된 파일 경로가 없습니다."); + return; + } + + try { + + String key = extractKeyFromUrl(storedPath); + + s3Template.deleteObject(bucket, key); + log.info("S3 파일 삭제 성공: {}", storedPath); + + } catch (S3Exception e) { + log.error("S3Exception - S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", bucket, storedPath, e.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_AMAZON_SERVICE_ERROR); + } catch (RuntimeException e) { + // 기존 AmazonClientException 역할 (네트워크 등 클라이언트 에러) + log.error("RuntimeException - S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", bucket, storedPath, e.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_AMAZON_CLIENT_ERROR); + } catch (Exception e) { + log.error("S3 파일 삭제 실패. 버킷: {}, 파일명: {}, 에러: {}", bucket, storedPath, e.getMessage()); + throw new CustomException(ErrorCode.S3_DELETE_ERROR); + + } + } + + private String extractKeyFromUrl(String fileUrl) { + if (fileUrl.contains(".com/")) { + String key = fileUrl.substring(fileUrl.lastIndexOf(".com/") + 5); + log.info("추출된 S3 Key: [{}]", key); + return key; + } + return fileUrl; + } + + +} diff --git a/src/main/java/com/campustable/be/domain/s3/util/FileUtil.java b/src/main/java/com/campustable/be/domain/s3/util/FileUtil.java new file mode 100644 index 0000000..f988f89 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/s3/util/FileUtil.java @@ -0,0 +1,13 @@ +package com.campustable.be.domain.s3.util; + +import lombok.experimental.UtilityClass; +import org.springframework.web.multipart.MultipartFile; + +@UtilityClass +public class FileUtil { + + public boolean isNullOrEmpty(MultipartFile file){ + return file == null || file.isEmpty() || file.getOriginalFilename() == null; + } + +} diff --git a/src/main/java/com/campustable/be/global/common/CommonUtil.java b/src/main/java/com/campustable/be/global/common/CommonUtil.java new file mode 100644 index 0000000..d1e9b24 --- /dev/null +++ b/src/main/java/com/campustable/be/global/common/CommonUtil.java @@ -0,0 +1,16 @@ +package com.campustable.be.global.common; + +public class CommonUtil { + + public static String nvl(String str1,String str2){ + if (str1 == null) { // str1 이 null 인 경우 + return str2; + } else if (str1.equals("null")) { // str1 이 문자열 "null" 인 경우 + return str2; + } else if (str1.isBlank()) { // str1 이 "" or " " 인 경우 + return str2; + } + return str1; + } + +} diff --git a/src/main/java/com/campustable/be/global/exception/ErrorCode.java b/src/main/java/com/campustable/be/global/exception/ErrorCode.java index 7c847ba..a59699b 100644 --- a/src/main/java/com/campustable/be/global/exception/ErrorCode.java +++ b/src/main/java/com/campustable/be/global/exception/ErrorCode.java @@ -77,10 +77,24 @@ public enum ErrorCode { //Order INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, "올바르지 않은 주문 상태 변경입니다."), + ORDER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 주문을 찾을 수 없습니다."), //OrderItem - ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 주문메뉴를 찾을 수 없습니다."); + ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 주문메뉴를 찾을 수 없습니다."), + + //S3 + INVALID_FILE_REQUEST(HttpStatus.BAD_REQUEST, "유효하지 않은 파일 요청입니다."), + + S3_UPLOAD_AMAZON_CLIENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 에러로 인해 파일 업로드에 실패했습니다."), + + S3_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 업로드 중 오류 발생"), + + S3_DELETE_AMAZON_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 서비스 에러로 인해 파일 삭제에 실패했습니다."), + + S3_DELETE_AMAZON_CLIENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 클라이언트 에러로 인해 파일 삭제에 실패했습니다."), + + S3_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S3 파일 삭제 중 오류 발생"); private final HttpStatus status; private final String message;