From 0a0517dc3ad04124911350687ca035791474fd7e Mon Sep 17 00:00:00 2001 From: limhb708 Date: Fri, 17 Apr 2026 22:22:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Feat:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 ID 기반 상세 조회 API 구현 - 게시글 조회 서비스 로직 구성 및 비즈니스 처리 추가 - Entity → Response DTO 매핑 로직 구현 - 조회 시 예외 처리 및 유효성 검증 로직 추가 Closes #145 --- .../board/controller/BoardController.java | 47 +++++++++++ .../board/dto/response/BoardResponse.java | 51 ++++++++++++ .../backend/board/service/BoardService.java | 8 ++ .../board/service/BoardServiceImpl.java | 16 ++++ .../board/controller/BoardControllerTest.java | 58 ++++++++++++++ .../board/service/BoardServiceTest.java | 80 +++++++++++++++++++ 6 files changed, 260 insertions(+) diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index e3b6e84..17b934b 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -78,4 +78,51 @@ public ResponseEntity createBoard( return ResponseEntity.ok(response); } + + /** + * 게시글 상세 조회 + */ + @Operation(summary = "특정 게시글 상세 조회", description = "게시글 ID를 기반으로 게시글 상세 정보를 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "게시글 상세 조회에 성공했습니다.", + content = @Content(schema = @Schema(implementation = BoardResponse.BoardDetailResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "400 Bad Request", + value = "{\"status\": 400, \"message\": \"잘못된 요청입니다.\"}"))), + @ApiResponse(responseCode = "401", description = "로그인이 필요한 기능입니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "401 Unauthorized", + value = "{\"status\": 401, \"message\": \"로그인이 필요한 기능입니다.\"}"))), + @ApiResponse(responseCode = "403", description = "특정 게시글을 조회할 권한이 없습니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "403 Forbidden", + value = "{\"status\": 403, \"message\": \"특정 게시글을 조회할 권한이 없습니다.\"}"))), + @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "404 Not Found", + value = "{\"status\": 404, \"message\": \"해당 ID의 게시글을 찾을 수 없습니다.\"}"))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = @ExampleObject(name = "500 Internal Server Error", + value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) + }) + @GetMapping("/{boardId}") + public ResponseEntity getBoardDetail( + @PathVariable Long boardId, + @AuthenticationPrincipal UserDetails userDetails + ) { + + UUID userId = UUID.fromString(userDetails.getUsername()); + log.info("게시글 상세 조회 요청 수신 - User: {}, BoardId: {}", userId, boardId); + + BoardResponse.BoardDetailResponse response = boardService.getBoardDetail(boardId); + + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java index b55f3ac..a35a5f3 100644 --- a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java +++ b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java @@ -37,4 +37,55 @@ public static BoardCreateResponse toDto(Long boardId, String message) { .build(); } } + + /** + * 게시글 상세 조회 응답 DTO + */ + @Getter + @Builder + @AllArgsConstructor + @Schema(description = "게시글 상세 조회 응답") + public static class BoardDetailResponse { + + @Schema(description = "응답 메시지", example = "게시글 상세 조회에 성공했습니다.") + private String message; + + @Schema(description = "게시글 ID", example = "123") + private Long boardId; + + @Schema(description = "게시글 제목", example = "저희 강아지 자랑합니다!") + private String boardTitle; + + @Schema(description = "게시글 내용", example = "오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") + private String boardContent; + + @Schema(description = "게시글 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + + @Schema(description = "작성자 닉네임", example = "자유로운영혼") + private String nickname; + + @Schema(description = "조회수", example = "51") + private Integer viewCount; + + @Schema(description = "게시글 생성 시각", example = "2025-10-06T10:00:00") + private LocalDateTime boardCreatedAt; + + @Schema(description = "게시글 수정 시각", example = "2025-10-06T11:00:00") + private LocalDateTime modifiedAt; + + public static BoardDetailResponse toDto(Board board, String message) { + return BoardDetailResponse.builder() + .message(message) + .boardId(board.getBoardId()) + .boardTitle(board.getBoardTitle()) + .boardContent(board.getBoardContent()) + .imageFileUrl(null) + .nickname(board.getUser().getNickname()) + .viewCount(board.getViewCount()) + .boardCreatedAt(board.getBoardCreatedAt()) + .modifiedAt(board.getModifiedAt()) + .build(); + } + } } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index f8d17f9..0b27682 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -24,4 +24,12 @@ public interface BoardService { */ Long createBoard(UUID userId, BoardCreateRequest request); + /** + * 게시글 상세 조회 + * + * @param boardId 게시글 ID + * @return 게시글 상세 조회 응답 DTO + */ + BoardResponse.BoardDetailResponse getBoardDetail(Long boardId); + } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index 64b1f5b..ceaeae0 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -2,6 +2,7 @@ import com.dodo.backend.board.dto.request.BoardRequest; +import com.dodo.backend.board.dto.response.BoardResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; @@ -65,4 +66,19 @@ public Long createBoard(UUID userId, BoardCreateRequest request) { return savedBoard.getBoardId(); } + + /** + * 게시글 상세 조회 + * + * @param boardId 게시글 ID + * @return 게시글 상세 조회 응답 DTO + */ + @Override + @Transactional(readOnly = true) + public BoardResponse.BoardDetailResponse getBoardDetail(Long boardId) { + + Board board = getBoardById(boardId); + + return BoardResponse.BoardDetailResponse.toDto(board, "게시글 상세 조회에 성공했습니다."); + } } \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java index 6c4f8bf..181b34d 100644 --- a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java +++ b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java @@ -13,6 +13,7 @@ import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -75,4 +76,61 @@ void createBoard_Success() { log.info("테스트 종료: 게시글 작성 성공 시나리오"); } + + /** + * 게시글 상세 조회 요청 시 200 상태코드와 게시글 상세 정보를 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 상세 조회 성공: 200 상태코드와 게시글 상세 정보를 반환한다.") + void getBoardDetail_Success() { + log.info("테스트 시작: 게시글 상세 조회 성공 시나리오"); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + Long boardId = 123L; + LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardResponse.BoardDetailResponse detailResponse = BoardResponse.BoardDetailResponse.builder() + .message("게시글 상세 조회에 성공했습니다.") + .boardId(boardId) + .boardTitle("저희 강아지 자랑합니다!") + .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") + .imageFileUrl("https://example.com/images/bori.jpg") + .nickname("자유로운영혼") + .viewCount(51) + .boardCreatedAt(boardCreatedAt) + .modifiedAt(null) + .build(); + + given(boardService.getBoardDetail(boardId)).willReturn(detailResponse); + + // when + ResponseEntity response = + boardController.getBoardDetail(boardId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글 상세 조회에 성공했습니다.", response.getBody().getMessage()); + assertEquals(boardId, response.getBody().getBoardId()); + assertEquals("저희 강아지 자랑합니다!", response.getBody().getBoardTitle()); + assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", response.getBody().getBoardContent()); + assertEquals("https://example.com/images/bori.jpg", response.getBody().getImageFileUrl()); + assertEquals("자유로운영혼", response.getBody().getNickname()); + assertEquals(51, response.getBody().getViewCount()); + assertEquals(boardCreatedAt, response.getBody().getBoardCreatedAt()); + assertEquals(null, response.getBody().getModifiedAt()); + + verify(boardService).getBoardDetail(boardId); + + log.info("테스트 종료: 게시글 상세 조회 성공 시나리오"); + } } \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index 32707d2..0222b60 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -1,9 +1,12 @@ package com.dodo.backend.board.service; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; +import com.dodo.backend.board.exception.BoardErrorCode; +import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; @@ -16,11 +19,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -97,4 +104,77 @@ void createBoard_Success() { log.info("테스트 종료: 게시글 작성 성공 검증 완료"); } + + /** + * 게시글 상세 조회 요청 시 게시글 상세 정보를 정상적으로 반환하는지 검증합니다. + */ + @Test + @DisplayName("게시글 상세 조회 성공: 게시글 상세 정보를 정상적으로 반환한다.") + void getBoardDetail_Success() { + log.info("테스트 시작: 게시글 상세 조회 성공"); + + // given + Long boardId = 123L; + LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + + User user = mock(User.class); + given(user.getNickname()).willReturn("자유로운영혼"); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("저희 강아지 자랑합니다!") + .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") + .viewCount(51) + .boardCreatedAt(boardCreatedAt) + .modifiedAt(null) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + + // when + BoardResponse.BoardDetailResponse response = boardService.getBoardDetail(boardId); + + // then + assertNotNull(response); + assertEquals("게시글 상세 조회에 성공했습니다.", response.getMessage()); + assertEquals(boardId, response.getBoardId()); + assertEquals("저희 강아지 자랑합니다!", response.getBoardTitle()); + assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", response.getBoardContent()); + assertNull(response.getImageFileUrl()); + assertEquals("자유로운영혼", response.getNickname()); + assertEquals(51, response.getViewCount()); + assertEquals(boardCreatedAt, response.getBoardCreatedAt()); + assertNull(response.getModifiedAt()); + + verify(boardRepository).findById(boardId); + + log.info("테스트 종료: 게시글 상세 조회 성공 검증 완료"); + } + + /** + * 존재하지 않는 게시글 상세 조회 요청 시 예외가 발생하는지 검증합니다. + */ + @Test + @DisplayName("게시글 상세 조회 실패: 존재하지 않는 게시글이면 예외가 발생한다.") + void getBoardDetail_Fail_BoardNotFound() { + log.info("테스트 시작: 게시글 상세 조회 실패 (게시글 없음)"); + + // given + Long boardId = 999L; + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); + + // when + BoardException exception = assertThrows(BoardException.class, () -> + boardService.getBoardDetail(boardId) + ); + + // then + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); + verify(boardRepository).findById(boardId); + + log.info("테스트 종료: 게시글 상세 조회 실패 (게시글 없음)"); + } } \ No newline at end of file From 66418674d77ed3e5e515ab8618868750e42e01dd Mon Sep 17 00:00:00 2001 From: limhb708 Date: Mon, 20 Apr 2026 19:31:30 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=B4=EC=99=84=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20URL=20=EC=9D=91=EB=8B=B5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 상세 조회 응답에 imageFileUrl 반영 - BoardService static import 적용 - BoardServiceImpl static import 적용 #146 코드 리뷰 반영 --- .../board/controller/BoardController.java | 34 ++--- .../board/dto/response/BoardResponse.java | 5 +- .../backend/board/service/BoardService.java | 11 +- .../board/service/BoardServiceImpl.java | 24 ++-- .../repository/ImageFileRepository.java | 78 ++++++++++++ .../imagefile/service/ImageFileService.java | 18 ++- .../service/ImageFileServiceImpl.java | 73 ++++++++++- .../board/service/BoardServiceTest.java | 117 +++++++----------- 8 files changed, 252 insertions(+), 108 deletions(-) diff --git a/src/main/java/com/dodo/backend/board/controller/BoardController.java b/src/main/java/com/dodo/backend/board/controller/BoardController.java index 17b934b..11f2519 100644 --- a/src/main/java/com/dodo/backend/board/controller/BoardController.java +++ b/src/main/java/com/dodo/backend/board/controller/BoardController.java @@ -1,7 +1,8 @@ package com.dodo.backend.board.controller; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardCreateResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.service.BoardService; import com.dodo.backend.common.exception.ErrorResponse; import io.swagger.v3.oas.annotations.Operation; @@ -11,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -20,6 +22,9 @@ import java.util.UUID; +/** + * 게시글 관련 HTTP 요청을 처리하는 컨트롤러입니다. + */ @RestController @RequiredArgsConstructor @RequestMapping("/boards") @@ -29,10 +34,13 @@ public class BoardController { private final BoardService boardService; + /** + * 게시글 작성 + */ @Operation(summary = "새로운 게시글 작성", description = "새로운 게시글을 작성합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "게시글이 성공적으로 작성되었습니다.", - content = @Content(schema = @Schema(implementation = BoardResponse.BoardCreateResponse.class))), + content = @Content(schema = @Schema(implementation = BoardCreateResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -48,23 +56,15 @@ public class BoardController { schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "403 Forbidden", value = "{\"status\": 403, \"message\": \"게시글을 생성할 권한이 없습니다.\"}"))), - @ApiResponse(responseCode = "404", description = "해당 ID의 게시글을 찾을 수 없습니다.", - content = @Content(mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = @ExampleObject(name = "404 Not Found", - value = "{\"status\": 404, \"message\": \"해당 ID의 게시글을 찾을 수 없습니다.\"}"))), @ApiResponse(responseCode = "500", description = "서버 내부 오류가 발생했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), examples = @ExampleObject(name = "500 Internal Server Error", value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) }) - /** - * 게시글 작성 - */ @PostMapping - public ResponseEntity createBoard( - @RequestBody BoardCreateRequest request, + public ResponseEntity createBoard( + @Valid @RequestBody BoardCreateRequest request, @AuthenticationPrincipal UserDetails userDetails ) { @@ -73,8 +73,8 @@ public ResponseEntity createBoard( Long boardId = boardService.createBoard(userId, request); - BoardResponse.BoardCreateResponse response = - BoardResponse.BoardCreateResponse.toDto(boardId, "게시글이 성공적으로 작성되었습니다."); + BoardCreateResponse response = + BoardCreateResponse.toDto(boardId, "게시글이 성공적으로 작성되었습니다."); return ResponseEntity.ok(response); } @@ -85,7 +85,7 @@ public ResponseEntity createBoard( @Operation(summary = "특정 게시글 상세 조회", description = "게시글 ID를 기반으로 게시글 상세 정보를 조회합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "게시글 상세 조회에 성공했습니다.", - content = @Content(schema = @Schema(implementation = BoardResponse.BoardDetailResponse.class))), + content = @Content(schema = @Schema(implementation = BoardDetailResponse.class))), @ApiResponse(responseCode = "400", description = "잘못된 요청입니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class), @@ -113,7 +113,7 @@ public ResponseEntity createBoard( value = "{\"status\": 500, \"message\": \"서버 내부 오류가 발생했습니다.\"}"))) }) @GetMapping("/{boardId}") - public ResponseEntity getBoardDetail( + public ResponseEntity getBoardDetail( @PathVariable Long boardId, @AuthenticationPrincipal UserDetails userDetails ) { @@ -121,7 +121,7 @@ public ResponseEntity getBoardDetail( UUID userId = UUID.fromString(userDetails.getUsername()); log.info("게시글 상세 조회 요청 수신 - User: {}, BoardId: {}", userId, boardId); - BoardResponse.BoardDetailResponse response = boardService.getBoardDetail(boardId); + BoardDetailResponse response = boardService.getBoardDetail(boardId); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java index a35a5f3..48ca5ab 100644 --- a/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java +++ b/src/main/java/com/dodo/backend/board/dto/response/BoardResponse.java @@ -5,7 +5,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; import java.time.LocalDateTime; @@ -74,13 +73,13 @@ public static class BoardDetailResponse { @Schema(description = "게시글 수정 시각", example = "2025-10-06T11:00:00") private LocalDateTime modifiedAt; - public static BoardDetailResponse toDto(Board board, String message) { + public static BoardDetailResponse toDto(Board board, String imageFileUrl, String message) { return BoardDetailResponse.builder() .message(message) .boardId(board.getBoardId()) .boardTitle(board.getBoardTitle()) .boardContent(board.getBoardContent()) - .imageFileUrl(null) + .imageFileUrl(imageFileUrl) .nickname(board.getUser().getNickname()) .viewCount(board.getViewCount()) .boardCreatedAt(board.getBoardCreatedAt()) diff --git a/src/main/java/com/dodo/backend/board/service/BoardService.java b/src/main/java/com/dodo/backend/board/service/BoardService.java index 0b27682..d1b4eda 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardService.java +++ b/src/main/java/com/dodo/backend/board/service/BoardService.java @@ -1,11 +1,11 @@ package com.dodo.backend.board.service; -import com.dodo.backend.board.dto.response.BoardResponse; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.entity.Board; -import com.dodo.backend.board.dto.request.BoardRequest; import java.util.UUID; + /** * 게시물(Board) 도메인의 비즈니스 로직을 처리하는 서비스 인터페이스입니다. */ @@ -21,6 +21,10 @@ public interface BoardService { /** * 게시글 생성 + * + * @param userId 요청 사용자 ID + * @param request 게시글 생성 요청 DTO + * @return 생성된 게시글 ID */ Long createBoard(UUID userId, BoardCreateRequest request); @@ -30,6 +34,5 @@ public interface BoardService { * @param boardId 게시글 ID * @return 게시글 상세 조회 응답 DTO */ - BoardResponse.BoardDetailResponse getBoardDetail(Long boardId); - + BoardDetailResponse getBoardDetail(Long boardId); } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index ceaeae0..ad6aebf 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -1,12 +1,11 @@ package com.dodo.backend.board.service; -import com.dodo.backend.board.dto.request.BoardRequest; - -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; -import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; +import com.dodo.backend.imagefile.service.ImageFileService; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.RequiredArgsConstructor; @@ -19,7 +18,7 @@ /** * {@link BoardService} 구현체입니다. - + * * 게시글 관련 비즈니스 로직을 처리하는 서비스 클래스입니다. */ @Service @@ -36,11 +35,16 @@ public class BoardServiceImpl implements BoardService { */ private final UserService userService; + /** + * 이미지 파일 처리를 위한 ImageFileService + */ + private final ImageFileService imageFileService; + /** * 게시글 ID로 게시글 엔티티 조회 */ - @Transactional(readOnly = true) @Override + @Transactional(readOnly = true) public Board getBoardById(Long boardId) { return boardRepository.findById(boardId) @@ -64,6 +68,8 @@ public Long createBoard(UUID userId, BoardCreateRequest request) { Board savedBoard = boardRepository.save(board); + imageFileService.saveBoardImages(savedBoard.getBoardId(), request.getImageFileUrls()); + return savedBoard.getBoardId(); } @@ -75,10 +81,12 @@ public Long createBoard(UUID userId, BoardCreateRequest request) { */ @Override @Transactional(readOnly = true) - public BoardResponse.BoardDetailResponse getBoardDetail(Long boardId) { + public BoardDetailResponse getBoardDetail(Long boardId) { Board board = getBoardById(boardId); - return BoardResponse.BoardDetailResponse.toDto(board, "게시글 상세 조회에 성공했습니다."); + String imageFileUrl = imageFileService.getBoardImageFileUrl(boardId); + + return BoardDetailResponse.toDto(board, imageFileUrl, "게시글 상세 조회에 성공했습니다."); } } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java index 2b1dde0..983ae75 100644 --- a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java +++ b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java @@ -2,6 +2,9 @@ import com.dodo.backend.imagefile.entity.ImageFile; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -25,9 +28,84 @@ public interface ImageFileRepository extends JpaRepository { * 여러 펫 ID들에 해당하는 이미지 파일들을 일괄 조회합니다. *

* 펫 목록 조회 시 N+1 문제를 방지하기 위해 사용됩니다. + *

* * @param petIds 조회할 펫 ID 목록 * @return 이미지 파일 엔티티 리스트 */ List findAllByPet_PetIdIn(List petIds); + + /** + * 기존 이미지 파일 row에 게시글 ID를 연결합니다. + * + * @param boardId 게시글 ID + * @param imageFileUrl 이미지 URL + * @return 수정된 row 수 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + UPDATE image_file + SET board_id = :boardId + WHERE image_file_url = :imageFileUrl + AND pet_id IS NULL + AND ( + board_id IS NULL + OR board_id = :boardId + ) + """, nativeQuery = true) + int updateBoardIdByImageFileUrl( + @Param("boardId") Long boardId, + @Param("imageFileUrl") String imageFileUrl + ); + + /** + * 게시글 이미지 파일 정보를 저장합니다. + *

+ * 기존 업로드 흐름에서 이미지 파일 row가 없는 경우 게시글 이미지 row를 새로 생성합니다. + *

+ * + * @param boardId 게시글 ID + * @param imageFileUrl 이미지 URL + * @param size 파일 크기 + * @param originalFilename 원본 파일명 + * @return 저장된 row 수 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + INSERT INTO image_file ( + board_id, + image_file_url, + size, + original_filename, + image_file_created_at + ) + VALUES ( + :boardId, + :imageFileUrl, + :size, + :originalFilename, + NOW() + ) + """, nativeQuery = true) + int insertBoardImageFile( + @Param("boardId") Long boardId, + @Param("imageFileUrl") String imageFileUrl, + @Param("size") Long size, + @Param("originalFilename") String originalFilename + ); + + /** + * 특정 게시글 ID에 해당하는 이미지 URL을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 대표 이미지 URL + */ + @Query(value = """ + SELECT image_file_url + FROM image_file + WHERE board_id = :boardId + ORDER BY image_file_created_at ASC + LIMIT 1 + """, nativeQuery = true) + Optional findFirstImageFileUrlByBoardId(@Param("boardId") Long boardId); } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java index c0fc2ec..c9ff03c 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java @@ -26,4 +26,20 @@ public interface ImageFileService { * @return 업로드 응답 DTO */ ImageUploadResponse uploadImages(List files); -} + + /** + * 게시글 이미지 URL 목록을 저장합니다. + * + * @param boardId 게시글 ID + * @param imageFileUrls 게시글 이미지 URL 목록 + */ + void saveBoardImages(Long boardId, List imageFileUrls); + + /** + * 게시글 ID에 해당하는 대표 이미지 URL을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 대표 이미지 URL + */ + String getBoardImageFileUrl(Long boardId); +} \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java index 5f2e7a3..b09cfd1 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java @@ -37,6 +37,9 @@ public class ImageFileServiceImpl implements ImageFileService { "image/gif" ); + private static final Long DEFAULT_BOARD_IMAGE_SIZE = 0L; + private static final String DEFAULT_BOARD_ORIGINAL_FILENAME = "board-image"; + private final ImageFileRepository imageFileRepository; private final Cloudinary cloudinary; private final Tika tika = new Tika(); @@ -85,6 +88,50 @@ public ImageUploadResponse uploadImages(List files) { return ImageUploadResponse.toDto("이미지 업로드에 성공하였습니다.", imageUrls); } + /** + * 게시글 이미지 URL 목록을 저장합니다. + * + * @param boardId 게시글 ID + * @param imageFileUrls 게시글 이미지 URL 목록 + */ + @Override + @Transactional + public void saveBoardImages(Long boardId, List imageFileUrls) { + + if (boardId == null || imageFileUrls == null || imageFileUrls.isEmpty()) { + return; + } + + List validImageFileUrls = imageFileUrls.stream() + .filter(imageFileUrl -> imageFileUrl != null && !imageFileUrl.isBlank()) + .distinct() + .toList(); + + if (validImageFileUrls.isEmpty()) { + return; + } + + validImageFileUrls.forEach(imageFileUrl -> saveBoardImage(boardId, imageFileUrl)); + } + + /** + * 게시글 ID에 해당하는 대표 이미지 URL을 조회합니다. + * + * @param boardId 게시글 ID + * @return 게시글 대표 이미지 URL + */ + @Override + @Transactional(readOnly = true) + public String getBoardImageFileUrl(Long boardId) { + + if (boardId == null) { + return null; + } + + return imageFileRepository.findFirstImageFileUrlByBoardId(boardId) + .orElse(null); + } + private String uploadSingleImage(MultipartFile file) { if (file == null || file.isEmpty()) { throw new UserException(INVALID_REQUEST); @@ -127,4 +174,28 @@ private String detectMimeType(MultipartFile file) { throw new UserException(INVALID_REQUEST); } } -} + + /** + * 단일 게시글 이미지를 저장합니다. + *

+ * 이미 존재하는 이미지 파일 row가 있으면 board_id를 연결하고, + * 존재하지 않으면 게시글 이미지 row를 새로 생성합니다. + *

+ * + * @param boardId 게시글 ID + * @param imageFileUrl 이미지 URL + */ + private void saveBoardImage(Long boardId, String imageFileUrl) { + + int updatedCount = imageFileRepository.updateBoardIdByImageFileUrl(boardId, imageFileUrl); + + if (updatedCount == 0) { + imageFileRepository.insertBoardImageFile( + boardId, + imageFileUrl, + DEFAULT_BOARD_IMAGE_SIZE, + DEFAULT_BOARD_ORIGINAL_FILENAME + ); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index 0222b60..928811a 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -1,22 +1,22 @@ package com.dodo.backend.board.service; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.entity.BoardStatus; import com.dodo.backend.board.entity.BoardType; import com.dodo.backend.board.exception.BoardErrorCode; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; +import com.dodo.backend.imagefile.service.ImageFileService; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; +import org.mockito.*; + import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; @@ -24,14 +24,9 @@ import java.util.Optional; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.*; /** * {@link BoardService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. @@ -49,24 +44,22 @@ class BoardServiceTest { @Mock private UserService userService; + @Mock + private ImageFileService imageFileService; // ⭐ 추가 + /** - * 게시글 작성 요청 시 작성자를 조회하고 게시글을 저장한 뒤 게시글 ID를 반환하는지 검증합니다. + * 게시글 작성 성공 테스트 */ @Test @DisplayName("게시글 작성 성공: 게시글이 정상적으로 저장되고 게시글 ID를 반환한다.") void createBoard_Success() { - log.info("테스트 시작: 게시글 작성 성공"); - // given UUID userId = UUID.randomUUID(); BoardCreateRequest request = BoardCreateRequest.builder() - .boardTitle("저희 강아지 자랑합니다!") - .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") - .imageFileUrls(List.of( - "https://example.com/image1.jpg", - "https://example.com/image2.jpg" - )) + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(List.of("url1", "url2")) .build(); User user = mock(User.class); @@ -74,59 +67,48 @@ void createBoard_Success() { Board savedBoard = Board.builder() .boardId(1L) .user(user) - .boardTitle(request.getBoardTitle()) - .boardContent(request.getBoardContent()) + .boardTitle("제목") + .boardContent("내용") .boardStatus(BoardStatus.PUBLISHED) .boardType(BoardType.FREE) .build(); given(userService.getUserById(userId)).willReturn(user); - given(boardRepository.save(any(Board.class))).willReturn(savedBoard); + given(boardRepository.save(any())).willReturn(savedBoard); // when Long boardId = boardService.createBoard(userId, request); // then - assertNotNull(boardId); assertEquals(1L, boardId); - ArgumentCaptor boardCaptor = ArgumentCaptor.forClass(Board.class); - verify(boardRepository).save(boardCaptor.capture()); + verify(boardRepository).save(any()); verify(userService).getUserById(userId); - Board board = boardCaptor.getValue(); - assertEquals(user, board.getUser()); - assertEquals("저희 강아지 자랑합니다!", board.getBoardTitle()); - assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", board.getBoardContent()); - assertEquals(BoardStatus.PUBLISHED, board.getBoardStatus()); - assertEquals(BoardType.FREE, board.getBoardType()); - assertEquals(0, board.getViewCount()); - - log.info("테스트 종료: 게시글 작성 성공 검증 완료"); + // ⭐ 추가 검증 + verify(imageFileService).saveBoardImages(1L, request.getImageFileUrls()); } /** - * 게시글 상세 조회 요청 시 게시글 상세 정보를 정상적으로 반환하는지 검증합니다. + * 게시글 상세 조회 성공 테스트 */ @Test - @DisplayName("게시글 상세 조회 성공: 게시글 상세 정보를 정상적으로 반환한다.") + @DisplayName("게시글 상세 조회 성공") void getBoardDetail_Success() { - log.info("테스트 시작: 게시글 상세 조회 성공"); - // given - Long boardId = 123L; - LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + Long boardId = 1L; + LocalDateTime time = LocalDateTime.now(); User user = mock(User.class); - given(user.getNickname()).willReturn("자유로운영혼"); + given(user.getNickname()).willReturn("닉네임"); Board board = Board.builder() .boardId(boardId) .user(user) - .boardTitle("저희 강아지 자랑합니다!") - .boardContent("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?") - .viewCount(51) - .boardCreatedAt(boardCreatedAt) + .boardTitle("제목") + .boardContent("내용") + .viewCount(10) + .boardCreatedAt(time) .modifiedAt(null) .boardStatus(BoardStatus.PUBLISHED) .boardType(BoardType.FREE) @@ -134,47 +116,34 @@ void getBoardDetail_Success() { given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + // ⭐ 추가 + given(imageFileService.getBoardImageFileUrl(boardId)) + .willReturn("image-url"); + // when - BoardResponse.BoardDetailResponse response = boardService.getBoardDetail(boardId); + BoardDetailResponse response = boardService.getBoardDetail(boardId); // then assertNotNull(response); - assertEquals("게시글 상세 조회에 성공했습니다.", response.getMessage()); - assertEquals(boardId, response.getBoardId()); - assertEquals("저희 강아지 자랑합니다!", response.getBoardTitle()); - assertEquals("오늘 산책하다 찍은 사진이에요. 너무 귀엽죠?", response.getBoardContent()); - assertNull(response.getImageFileUrl()); - assertEquals("자유로운영혼", response.getNickname()); - assertEquals(51, response.getViewCount()); - assertEquals(boardCreatedAt, response.getBoardCreatedAt()); - assertNull(response.getModifiedAt()); - - verify(boardRepository).findById(boardId); - - log.info("테스트 종료: 게시글 상세 조회 성공 검증 완료"); + assertEquals("image-url", response.getImageFileUrl()); + + verify(imageFileService).getBoardImageFileUrl(boardId); } /** - * 존재하지 않는 게시글 상세 조회 요청 시 예외가 발생하는지 검증합니다. + * 게시글 없음 예외 테스트 */ @Test - @DisplayName("게시글 상세 조회 실패: 존재하지 않는 게시글이면 예외가 발생한다.") - void getBoardDetail_Fail_BoardNotFound() { - log.info("테스트 시작: 게시글 상세 조회 실패 (게시글 없음)"); + @DisplayName("게시글 조회 실패: 존재하지 않음") + void getBoardDetail_Fail() { - // given Long boardId = 999L; - given(boardRepository.findById(boardId)).willReturn(Optional.empty()); - // when - BoardException exception = assertThrows(BoardException.class, () -> - boardService.getBoardDetail(boardId) - ); + given(boardRepository.findById(boardId)).willReturn(Optional.empty()); - // then - assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); - verify(boardRepository).findById(boardId); + BoardException ex = assertThrows(BoardException.class, + () -> boardService.getBoardDetail(boardId)); - log.info("테스트 종료: 게시글 상세 조회 실패 (게시글 없음)"); + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, ex.getErrorCode()); } } \ No newline at end of file From f68531b28f7c76bfe01b2bb4b10c1fe963b6a567 Mon Sep 17 00:00:00 2001 From: limhb708 Date: Wed, 22 Apr 2026 10:39:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5/=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20optional=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게시글 이미지 저장 로직을 JPA saveAll() 방식으로 변경 - 이미지 URL optional 처리 (null/empty 허용) - orElse(null) 제거 및 Optional 처리 방식 개선 - 불필요한 return 제거 및 예외 처리 보완 #146 코드 리뷰 반영 --- .../board/dto/request/BoardRequest.java | 2 +- .../board/service/BoardServiceImpl.java | 109 +++++++- .../backend/imagefile/entity/ImageFile.java | 14 +- .../repository/ImageFileRepository.java | 75 +----- .../imagefile/service/ImageFileService.java | 16 -- .../service/ImageFileServiceImpl.java | 102 ++------ .../board/controller/BoardControllerTest.java | 152 ++++++++++-- .../board/service/BoardServiceTest.java | 233 ++++++++++++++++-- 8 files changed, 473 insertions(+), 230 deletions(-) diff --git a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java index 9e2347b..f0aeb09 100644 --- a/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java +++ b/src/main/java/com/dodo/backend/board/dto/request/BoardRequest.java @@ -34,7 +34,7 @@ public static class BoardCreateRequest { @NotBlank(message = "내용은 필수입니다.") private String boardContent; - @Schema(description = "게시글 이미지 URL 목록") + @Schema(description = "게시글 이미지 URL 목록", nullable = true) private List imageFileUrls; /** diff --git a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java index ad6aebf..2b21701 100644 --- a/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java +++ b/src/main/java/com/dodo/backend/board/service/BoardServiceImpl.java @@ -5,16 +5,19 @@ import com.dodo.backend.board.entity.Board; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; -import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.repository.ImageFileRepository; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.UUID; import static com.dodo.backend.board.exception.BoardErrorCode.BOARD_NOT_FOUND; +import static com.dodo.backend.board.exception.BoardErrorCode.INVALID_REQUEST; /** * {@link BoardService} 구현체입니다. @@ -31,14 +34,14 @@ public class BoardServiceImpl implements BoardService { private final BoardRepository boardRepository; /** - * 사용자 조회를 위한 UserService + * 이미지 파일 저장 / 조회를 위한 Repository */ - private final UserService userService; + private final ImageFileRepository imageFileRepository; /** - * 이미지 파일 처리를 위한 ImageFileService + * 사용자 조회를 위한 UserService */ - private final ImageFileService imageFileService; + private final UserService userService; /** * 게시글 ID로 게시글 엔티티 조회 @@ -47,6 +50,8 @@ public class BoardServiceImpl implements BoardService { @Transactional(readOnly = true) public Board getBoardById(Long boardId) { + validateBoardId(boardId); + return boardRepository.findById(boardId) .orElseThrow(() -> new BoardException(BOARD_NOT_FOUND)); } @@ -65,10 +70,9 @@ public Long createBoard(UUID userId, BoardCreateRequest request) { User user = userService.getUserById(userId); Board board = request.toEntity(user); - Board savedBoard = boardRepository.save(board); - imageFileService.saveBoardImages(savedBoard.getBoardId(), request.getImageFileUrls()); + saveBoardImages(savedBoard, request.getImageFileUrls()); return savedBoard.getBoardId(); } @@ -85,8 +89,97 @@ public BoardDetailResponse getBoardDetail(Long boardId) { Board board = getBoardById(boardId); - String imageFileUrl = imageFileService.getBoardImageFileUrl(boardId); + String imageFileUrl = imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId) + .map(ImageFile::getImageFileUrl) + .orElseGet(() -> null); return BoardDetailResponse.toDto(board, imageFileUrl, "게시글 상세 조회에 성공했습니다."); } + + /** + * 게시글 ID를 검증합니다. + * + * @param boardId 게시글 ID + */ + private void validateBoardId(Long boardId) { + + if (boardId == null) { + throw new BoardException(INVALID_REQUEST); + } + } + + /** + * 게시글 이미지 URL 목록을 저장합니다. + * + * @param board 게시글 엔티티 + * @param imageFileUrls 게시글 이미지 URL 목록 + */ + private void saveBoardImages(Board board, List imageFileUrls) { + + if (board == null) { + throw new BoardException(INVALID_REQUEST); + } + + List validImageFileUrls = getValidImageFileUrls(imageFileUrls); + + if (validImageFileUrls.isEmpty()) { + return; + } + + List imageFiles = validImageFileUrls.stream() + .map(imageFileUrl -> ImageFile.builder() + .board(board) + .imageFileUrl(imageFileUrl) + .size(0L) + .originalFilename(extractOriginalFilename(imageFileUrl)) + .build()) + .toList(); + + imageFileRepository.saveAll(imageFiles); + } + + /** + * 유효한 이미지 URL 목록을 반환합니다. + * + * @param imageFileUrls 이미지 URL 목록 + * @return 유효한 이미지 URL 목록 + */ + private List getValidImageFileUrls(List imageFileUrls) { + + if (imageFileUrls == null || imageFileUrls.isEmpty()) { + return List.of(); + } + + return imageFileUrls.stream() + .filter(imageFileUrl -> imageFileUrl != null && !imageFileUrl.isBlank()) + .map(String::trim) + .distinct() + .toList(); + } + + /** + * 이미지 URL에서 원본 파일명으로 사용할 값을 추출합니다. + * + * @param imageFileUrl 이미지 URL + * @return 추출된 파일명 + */ + private String extractOriginalFilename(String imageFileUrl) { + + String urlWithoutQueryString = imageFileUrl.split("\\?")[0]; + int lastSlashIndex = urlWithoutQueryString.lastIndexOf('/'); + + String originalFilename = lastSlashIndex >= 0 + ? urlWithoutQueryString.substring(lastSlashIndex + 1) + : urlWithoutQueryString; + + if (originalFilename.isBlank()) { + return "board-image"; + } + + if (originalFilename.length() <= 255) { + return originalFilename; + } + + return originalFilename.substring(originalFilename.length() - 255); + } } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java b/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java index 27746e4..139fc4a 100644 --- a/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java +++ b/src/main/java/com/dodo/backend/imagefile/entity/ImageFile.java @@ -1,5 +1,6 @@ package com.dodo.backend.imagefile.entity; +import com.dodo.backend.board.entity.Board; import com.dodo.backend.pet.entity.Pet; import jakarta.persistence.*; import lombok.*; @@ -9,10 +10,11 @@ import java.time.LocalDateTime; /** - * 펫의 프로필 이미지 파일 정보를 관리하는 엔티티입니다. + * 이미지 파일 정보를 관리하는 엔티티입니다. *

- * {@link Pet} 엔티티와 1:1 관계를 맺으며, 파일의 메타데이터(크기, 원본명 등)와 - * 저장된 URL 경로를 포함합니다. + * 펫 프로필 이미지 또는 게시글 이미지로 사용될 수 있으며, + * 파일의 메타데이터(크기, 원본명 등)와 저장된 URL 경로를 포함합니다. + *

*/ @Entity @Getter @@ -29,9 +31,13 @@ public class ImageFile { private Long imageFileId; @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "pet_id", nullable = false, unique = true) + @JoinColumn(name = "pet_id", unique = true) private Pet pet; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + private Board board; + @Column(name = "image_file_url", length = 2048, nullable = false) private String imageFileUrl; diff --git a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java index 983ae75..a9e8ca1 100644 --- a/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java +++ b/src/main/java/com/dodo/backend/imagefile/repository/ImageFileRepository.java @@ -2,9 +2,6 @@ import com.dodo.backend.imagefile.entity.ImageFile; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -36,76 +33,10 @@ public interface ImageFileRepository extends JpaRepository { List findAllByPet_PetIdIn(List petIds); /** - * 기존 이미지 파일 row에 게시글 ID를 연결합니다. + * 특정 게시글 ID에 해당하는 첫 번째 이미지 파일을 조회합니다. * * @param boardId 게시글 ID - * @param imageFileUrl 이미지 URL - * @return 수정된 row 수 + * @return 게시글 대표 이미지 파일 */ - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(value = """ - UPDATE image_file - SET board_id = :boardId - WHERE image_file_url = :imageFileUrl - AND pet_id IS NULL - AND ( - board_id IS NULL - OR board_id = :boardId - ) - """, nativeQuery = true) - int updateBoardIdByImageFileUrl( - @Param("boardId") Long boardId, - @Param("imageFileUrl") String imageFileUrl - ); - - /** - * 게시글 이미지 파일 정보를 저장합니다. - *

- * 기존 업로드 흐름에서 이미지 파일 row가 없는 경우 게시글 이미지 row를 새로 생성합니다. - *

- * - * @param boardId 게시글 ID - * @param imageFileUrl 이미지 URL - * @param size 파일 크기 - * @param originalFilename 원본 파일명 - * @return 저장된 row 수 - */ - @Modifying(clearAutomatically = true, flushAutomatically = true) - @Query(value = """ - INSERT INTO image_file ( - board_id, - image_file_url, - size, - original_filename, - image_file_created_at - ) - VALUES ( - :boardId, - :imageFileUrl, - :size, - :originalFilename, - NOW() - ) - """, nativeQuery = true) - int insertBoardImageFile( - @Param("boardId") Long boardId, - @Param("imageFileUrl") String imageFileUrl, - @Param("size") Long size, - @Param("originalFilename") String originalFilename - ); - - /** - * 특정 게시글 ID에 해당하는 이미지 URL을 조회합니다. - * - * @param boardId 게시글 ID - * @return 게시글 대표 이미지 URL - */ - @Query(value = """ - SELECT image_file_url - FROM image_file - WHERE board_id = :boardId - ORDER BY image_file_created_at ASC - LIMIT 1 - """, nativeQuery = true) - Optional findFirstImageFileUrlByBoardId(@Param("boardId") Long boardId); + Optional findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(Long boardId); } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java index c9ff03c..a6abb0d 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java @@ -26,20 +26,4 @@ public interface ImageFileService { * @return 업로드 응답 DTO */ ImageUploadResponse uploadImages(List files); - - /** - * 게시글 이미지 URL 목록을 저장합니다. - * - * @param boardId 게시글 ID - * @param imageFileUrls 게시글 이미지 URL 목록 - */ - void saveBoardImages(Long boardId, List imageFileUrls); - - /** - * 게시글 ID에 해당하는 대표 이미지 URL을 조회합니다. - * - * @param boardId 게시글 ID - * @return 게시글 대표 이미지 URL - */ - String getBoardImageFileUrl(Long boardId); } \ No newline at end of file diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java index b09cfd1..af0980e 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java @@ -5,6 +5,7 @@ import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; import com.dodo.backend.imagefile.entity.ImageFile; import com.dodo.backend.imagefile.repository.ImageFileRepository; +import com.dodo.backend.user.exception.UserErrorCode; import com.dodo.backend.user.exception.UserException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,8 +21,6 @@ import java.util.Set; import java.util.stream.Collectors; -import static com.dodo.backend.user.exception.UserErrorCode.INVALID_REQUEST; - /** * {@link ImageFileService}의 구현체입니다. */ @@ -37,22 +36,12 @@ public class ImageFileServiceImpl implements ImageFileService { "image/gif" ); - private static final Long DEFAULT_BOARD_IMAGE_SIZE = 0L; - private static final String DEFAULT_BOARD_ORIGINAL_FILENAME = "board-image"; - private final ImageFileRepository imageFileRepository; private final Cloudinary cloudinary; private final Tika tika = new Tika(); /** * {@inheritDoc} - *

- * 처리 과정: - *

    - *
  1. 입력된 펫 ID 목록이 비어있으면 빈 Map을 반환합니다.
  2. - *
  3. 리포지토리의 {@code findAllByPet_PetIdIn}을 호출하여 펫 ID 목록에 해당하는 이미지 파일들을 조회합니다.
  4. - *
  5. 조회된 엔티티에서 펫 ID와 이미지 URL을 추출하여 Map으로 변환합니다.
  6. - *
*/ @Override @Transactional(readOnly = true) @@ -66,7 +55,7 @@ public Map getProfileUrlsByPetIds(List petIds) { return images.stream() .collect(Collectors.toMap( - img -> img.getPet().getPetId(), + image -> image.getPet().getPetId(), ImageFile::getImageFileUrl )); } @@ -74,11 +63,12 @@ public Map getProfileUrlsByPetIds(List petIds) { /** * {@inheritDoc} */ - @Transactional(readOnly = true) @Override + @Transactional(readOnly = true) public ImageUploadResponse uploadImages(List files) { + if (files == null || files.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new UserException(UserErrorCode.INVALID_REQUEST); } List imageUrls = files.stream() @@ -89,62 +79,24 @@ public ImageUploadResponse uploadImages(List files) { } /** - * 게시글 이미지 URL 목록을 저장합니다. + * 단일 이미지를 Cloudinary에 업로드하고 URL을 반환합니다. * - * @param boardId 게시글 ID - * @param imageFileUrls 게시글 이미지 URL 목록 + * @param file 업로드할 이미지 파일 + * @return 업로드된 이미지 URL */ - @Override - @Transactional - public void saveBoardImages(Long boardId, List imageFileUrls) { - - if (boardId == null || imageFileUrls == null || imageFileUrls.isEmpty()) { - return; - } - - List validImageFileUrls = imageFileUrls.stream() - .filter(imageFileUrl -> imageFileUrl != null && !imageFileUrl.isBlank()) - .distinct() - .toList(); - - if (validImageFileUrls.isEmpty()) { - return; - } - - validImageFileUrls.forEach(imageFileUrl -> saveBoardImage(boardId, imageFileUrl)); - } - - /** - * 게시글 ID에 해당하는 대표 이미지 URL을 조회합니다. - * - * @param boardId 게시글 ID - * @return 게시글 대표 이미지 URL - */ - @Override - @Transactional(readOnly = true) - public String getBoardImageFileUrl(Long boardId) { - - if (boardId == null) { - return null; - } - - return imageFileRepository.findFirstImageFileUrlByBoardId(boardId) - .orElse(null); - } - private String uploadSingleImage(MultipartFile file) { if (file == null || file.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new UserException(UserErrorCode.INVALID_REQUEST); } String contentType = file.getContentType(); if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { - throw new UserException(INVALID_REQUEST); + throw new UserException(UserErrorCode.INVALID_REQUEST); } String detectedMimeType = detectMimeType(file); if (!ALLOWED_MIME_TYPES.contains(detectedMimeType)) { - throw new UserException(INVALID_REQUEST); + throw new UserException(UserErrorCode.INVALID_REQUEST); } try { @@ -167,35 +119,17 @@ private String uploadSingleImage(MultipartFile file) { } } + /** + * 실제 파일 내용 기반으로 MIME 타입을 감지합니다. + * + * @param file MIME 타입을 확인할 이미지 파일 + * @return 감지된 MIME 타입 + */ private String detectMimeType(MultipartFile file) { try { return tika.detect(file.getBytes()); } catch (Exception e) { - throw new UserException(INVALID_REQUEST); - } - } - - /** - * 단일 게시글 이미지를 저장합니다. - *

- * 이미 존재하는 이미지 파일 row가 있으면 board_id를 연결하고, - * 존재하지 않으면 게시글 이미지 row를 새로 생성합니다. - *

- * - * @param boardId 게시글 ID - * @param imageFileUrl 이미지 URL - */ - private void saveBoardImage(Long boardId, String imageFileUrl) { - - int updatedCount = imageFileRepository.updateBoardIdByImageFileUrl(boardId, imageFileUrl); - - if (updatedCount == 0) { - imageFileRepository.insertBoardImageFile( - boardId, - imageFileUrl, - DEFAULT_BOARD_IMAGE_SIZE, - DEFAULT_BOARD_ORIGINAL_FILENAME - ); + throw new UserException(UserErrorCode.INVALID_REQUEST); } } } \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java index 181b34d..0d84f81 100644 --- a/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java +++ b/src/test/java/com/dodo/backend/board/controller/BoardControllerTest.java @@ -1,7 +1,8 @@ package com.dodo.backend.board.controller; import com.dodo.backend.board.dto.request.BoardRequest.BoardCreateRequest; -import com.dodo.backend.board.dto.response.BoardResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardCreateResponse; +import com.dodo.backend.board.dto.response.BoardResponse.BoardDetailResponse; import com.dodo.backend.board.service.BoardService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; @@ -17,13 +18,17 @@ import java.util.List; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; /** * {@link BoardController}의 HTTP 요청 처리 로직을 검증하는 테스트 클래스입니다. + *

+ * 게시글 작성 및 게시글 상세 조회 API 호출 시, + * 컨트롤러가 요청 데이터를 올바르게 서비스 계층으로 전달하고 + * 기대한 응답 DTO를 반환하는지 검증합니다. + *

*/ @Slf4j @ExtendWith(MockitoExtension.class) @@ -33,14 +38,19 @@ class BoardControllerTest { private BoardService boardService; /** - * 게시글 작성 요청 시 200 상태코드와 생성된 게시글 ID를 반환하는지 검증합니다. + * 이미지 URL이 포함된 게시글 작성 요청 성공 시나리오를 테스트합니다. + *

+ * 인증된 사용자 정보와 게시글 작성 요청 DTO를 전달했을 때, + * 컨트롤러가 {@link BoardService#createBoard(UUID, BoardCreateRequest)}를 호출하고 + * 생성된 게시글 ID와 성공 메시지를 반환하는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 작성 성공: 200 상태코드와 게시글 ID를 반환한다.") - void createBoard_Success() { - log.info("테스트 시작: 게시글 작성 성공 시나리오"); + @DisplayName("게시글 작성 성공: 이미지가 있어도 정상 응답을 반환한다.") + void createBoard_Success_WithImages() { + log.info("이미지가 포함된 게시글 작성 성공 테스트를 시작합니다."); - // given + // Given: 게시글 작성 요청 데이터와 인증 사용자 정보를 준비합니다. BoardController boardController = new BoardController(boardService); UUID userId = UUID.randomUUID(); @@ -61,8 +71,54 @@ void createBoard_Success() { given(boardService.createBoard(userId, request)).willReturn(1L); + // When: 게시글 작성 API를 호출합니다. + ResponseEntity response = + boardController.createBoard(request, userDetails); + + // Then: 응답 상태, 메시지, 게시글 ID를 검증합니다. + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("게시글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); + assertEquals(1L, response.getBody().getBoardId()); + + verify(boardService).createBoard(userId, request); + + log.info("이미지가 포함된 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 작성 요청 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 {@code imageFileUrls}가 null이어도 + * 게시글 생성 요청이 정상적으로 처리되는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 이미지가 없어도 정상 응답을 반환한다.") + void createBoard_Success_WithoutImages() { + log.info("이미지가 없는 게시글 작성 성공 테스트를 시작합니다."); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("이미지 없는 게시글") + .boardContent("이미지 없이 작성합니다.") + .imageFileUrls(null) + .build(); + + given(boardService.createBoard(userId, request)).willReturn(2L); + // when - ResponseEntity response = + ResponseEntity response = boardController.createBoard(request, userDetails); // then @@ -70,20 +126,24 @@ void createBoard_Success() { assertEquals(200, response.getStatusCode().value()); assertNotNull(response.getBody()); assertEquals("게시글이 성공적으로 작성되었습니다.", response.getBody().getMessage()); - assertEquals(1L, response.getBody().getBoardId()); + assertEquals(2L, response.getBody().getBoardId()); verify(boardService).createBoard(userId, request); - log.info("테스트 종료: 게시글 작성 성공 시나리오"); + log.info("이미지가 없는 게시글 작성 성공 테스트가 통과되었습니다."); } /** - * 게시글 상세 조회 요청 시 200 상태코드와 게시글 상세 정보를 반환하는지 검증합니다. + * 이미지 URL이 있는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 게시글 상세 조회 요청 시 서비스 계층에서 반환한 대표 이미지 URL이 + * 응답 DTO의 {@code imageFileUrl} 필드에 포함되는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 상세 조회 성공: 200 상태코드와 게시글 상세 정보를 반환한다.") - void getBoardDetail_Success() { - log.info("테스트 시작: 게시글 상세 조회 성공 시나리오"); + @DisplayName("게시글 상세 조회 성공: 이미지가 있으면 imageFileUrl을 반환한다.") + void getBoardDetail_Success_WithImage() { + log.info("이미지가 있는 게시글 상세 조회 성공 테스트를 시작합니다."); // given BoardController boardController = new BoardController(boardService); @@ -97,7 +157,7 @@ void getBoardDetail_Success() { .authorities(List.of()) .build(); - BoardResponse.BoardDetailResponse detailResponse = BoardResponse.BoardDetailResponse.builder() + BoardDetailResponse detailResponse = BoardDetailResponse.builder() .message("게시글 상세 조회에 성공했습니다.") .boardId(boardId) .boardTitle("저희 강아지 자랑합니다!") @@ -112,7 +172,7 @@ void getBoardDetail_Success() { given(boardService.getBoardDetail(boardId)).willReturn(detailResponse); // when - ResponseEntity response = + ResponseEntity response = boardController.getBoardDetail(boardId, userDetails); // then @@ -127,10 +187,64 @@ void getBoardDetail_Success() { assertEquals("자유로운영혼", response.getBody().getNickname()); assertEquals(51, response.getBody().getViewCount()); assertEquals(boardCreatedAt, response.getBody().getBoardCreatedAt()); - assertEquals(null, response.getBody().getModifiedAt()); + assertNull(response.getBody().getModifiedAt()); + + verify(boardService).getBoardDetail(boardId); + + log.info("이미지가 있는 게시글 상세 조회 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 게시글에 이미지가 연결되어 있지 않아도 + * {@code imageFileUrl}이 null인 정상 응답을 반환하는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 상세 조회 성공: 이미지가 없으면 imageFileUrl은 null이다.") + void getBoardDetail_Success_WithoutImage() { + log.info("이미지가 없는 게시글 상세 조회 성공 테스트를 시작합니다."); + + // given + BoardController boardController = new BoardController(boardService); + + UUID userId = UUID.randomUUID(); + Long boardId = 124L; + LocalDateTime boardCreatedAt = LocalDateTime.of(2025, 10, 6, 10, 0); + + UserDetails userDetails = User.withUsername(userId.toString()) + .password("password") + .authorities(List.of()) + .build(); + + BoardDetailResponse detailResponse = BoardDetailResponse.builder() + .message("게시글 상세 조회에 성공했습니다.") + .boardId(boardId) + .boardTitle("이미지 없는 게시글") + .boardContent("이미지 없이 작성된 게시글입니다.") + .imageFileUrl(null) + .nickname("자유로운영혼") + .viewCount(3) + .boardCreatedAt(boardCreatedAt) + .modifiedAt(null) + .build(); + + given(boardService.getBoardDetail(boardId)).willReturn(detailResponse); + + // when + ResponseEntity response = + boardController.getBoardDetail(boardId, userDetails); + + // then + assertNotNull(response); + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(boardId, response.getBody().getBoardId()); + assertNull(response.getBody().getImageFileUrl()); verify(boardService).getBoardDetail(boardId); - log.info("테스트 종료: 게시글 상세 조회 성공 시나리오"); + log.info("이미지가 없는 게시글 상세 조회 성공 테스트가 통과되었습니다."); } } \ No newline at end of file diff --git a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java index 928811a..9b84219 100644 --- a/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java +++ b/src/test/java/com/dodo/backend/board/service/BoardServiceTest.java @@ -8,15 +8,16 @@ import com.dodo.backend.board.exception.BoardErrorCode; import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.board.repository.BoardRepository; -import com.dodo.backend.imagefile.service.ImageFileService; +import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.repository.ImageFileRepository; import com.dodo.backend.user.entity.User; import com.dodo.backend.user.service.UserService; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.*; - +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; @@ -30,6 +31,10 @@ /** * {@link BoardService}의 비즈니스 로직을 검증하는 테스트 클래스입니다. + *

+ * 게시글 생성, 게시글 상세 조회, 이미지 URL 저장 및 조회 흐름을 + * Mock 객체 기반으로 검증합니다. + *

*/ @Slf4j @ExtendWith(MockitoExtension.class) @@ -42,18 +47,24 @@ class BoardServiceTest { private BoardRepository boardRepository; @Mock - private UserService userService; + private ImageFileRepository imageFileRepository; @Mock - private ImageFileService imageFileService; // ⭐ 추가 + private UserService userService; /** - * 게시글 작성 성공 테스트 + * 이미지 URL이 포함된 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 게시글이 정상적으로 저장된 뒤, 요청에 포함된 이미지 URL 목록이 + * {@link ImageFileRepository#saveAll(Iterable)}을 통해 저장되는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 작성 성공: 게시글이 정상적으로 저장되고 게시글 ID를 반환한다.") - void createBoard_Success() { + @DisplayName("게시글 작성 성공: 이미지가 있으면 image_file도 저장한다.") + void createBoard_Success_WithImages() { + log.info("이미지가 포함된 게시글 작성 성공 테스트를 시작합니다."); + // given UUID userId = UUID.randomUUID(); BoardCreateRequest request = BoardCreateRequest.builder() @@ -74,7 +85,7 @@ void createBoard_Success() { .build(); given(userService.getUserById(userId)).willReturn(user); - given(boardRepository.save(any())).willReturn(savedBoard); + given(boardRepository.save(any(Board.class))).willReturn(savedBoard); // when Long boardId = boardService.createBoard(userId, request); @@ -82,20 +93,122 @@ void createBoard_Success() { // then assertEquals(1L, boardId); - verify(boardRepository).save(any()); verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository).saveAll(anyList()); + + log.info("이미지가 포함된 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 이미지 URL이 없는 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 {@code imageFileUrls}가 null이어도 + * 게시글은 저장되고 이미지 파일 저장은 호출되지 않아야 합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 이미지가 없으면 image_file은 저장하지 않는다.") + void createBoard_Success_WithoutImages() { + log.info("이미지가 없는 게시글 작성 성공 테스트를 시작합니다."); + + // given + UUID userId = UUID.randomUUID(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(null) + .build(); + + User user = mock(User.class); + + Board savedBoard = Board.builder() + .boardId(2L) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(boardRepository.save(any(Board.class))).willReturn(savedBoard); + + // when + Long boardId = boardService.createBoard(userId, request); + + // then + assertEquals(2L, boardId); + + verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository, never()).saveAll(anyList()); + + log.info("이미지가 없는 게시글 작성 성공 테스트가 통과되었습니다."); + } + + /** + * 빈 이미지 URL만 전달된 게시글 작성 성공 시나리오를 테스트합니다. + *

+ * 이미지 URL 목록에 빈 문자열 또는 공백 문자열만 포함된 경우, + * 유효한 이미지가 없는 것으로 판단하여 이미지 파일 저장을 수행하지 않는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 작성 성공: 빈 이미지 URL만 있으면 저장하지 않는다.") + void createBoard_Success_BlankImages() { + log.info("빈 이미지 URL만 포함된 게시글 작성 성공 테스트를 시작합니다."); + + // given + UUID userId = UUID.randomUUID(); + + BoardCreateRequest request = BoardCreateRequest.builder() + .boardTitle("제목") + .boardContent("내용") + .imageFileUrls(List.of("", " ")) + .build(); + + User user = mock(User.class); + + Board savedBoard = Board.builder() + .boardId(3L) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + + given(userService.getUserById(userId)).willReturn(user); + given(boardRepository.save(any(Board.class))).willReturn(savedBoard); + + // when + Long boardId = boardService.createBoard(userId, request); + + // then + assertEquals(3L, boardId); + + verify(userService).getUserById(userId); + verify(boardRepository).save(any(Board.class)); + verify(imageFileRepository, never()).saveAll(anyList()); - // ⭐ 추가 검증 - verify(imageFileService).saveBoardImages(1L, request.getImageFileUrls()); + log.info("빈 이미지 URL만 포함된 게시글 작성 성공 테스트가 통과되었습니다."); } /** - * 게시글 상세 조회 성공 테스트 + * 이미지 URL이 있는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 게시글이 존재하고 연결된 이미지가 있을 때, + * 대표 이미지 URL이 상세 응답 DTO에 포함되는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 상세 조회 성공") - void getBoardDetail_Success() { + @DisplayName("게시글 상세 조회 성공: 이미지가 있으면 imageFileUrl을 반환한다.") + void getBoardDetail_Success_WithImage() { + log.info("이미지가 있는 게시글 상세 조회 성공 테스트를 시작합니다."); + // given Long boardId = 1L; LocalDateTime time = LocalDateTime.now(); @@ -114,11 +227,16 @@ void getBoardDetail_Success() { .boardType(BoardType.FREE) .build(); - given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + ImageFile imageFile = ImageFile.builder() + .imageFileUrl("image-url") + .size(0L) + .originalFilename("board-image") + .board(board) + .build(); - // ⭐ 추가 - given(imageFileService.getBoardImageFileUrl(boardId)) - .willReturn("image-url"); + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + given(imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId)) + .willReturn(Optional.of(imageFile)); // when BoardDetailResponse response = boardService.getBoardDetail(boardId); @@ -127,23 +245,86 @@ void getBoardDetail_Success() { assertNotNull(response); assertEquals("image-url", response.getImageFileUrl()); - verify(imageFileService).getBoardImageFileUrl(boardId); + verify(boardRepository).findById(boardId); + verify(imageFileRepository).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId); + + log.info("이미지가 있는 게시글 상세 조회 성공 테스트가 통과되었습니다."); } /** - * 게시글 없음 예외 테스트 + * 이미지 URL이 없는 게시글 상세 조회 성공 시나리오를 테스트합니다. + *

+ * 이미지가 필수가 아닌 정책에 따라 게시글에 연결된 이미지가 없을 경우, + * 상세 응답의 {@code imageFileUrl}이 null로 반환되는지 검증합니다. + *

*/ @Test - @DisplayName("게시글 조회 실패: 존재하지 않음") - void getBoardDetail_Fail() { + @DisplayName("게시글 상세 조회 성공: 이미지가 없으면 null을 반환한다.") + void getBoardDetail_Success_WithoutImage() { + log.info("이미지가 없는 게시글 상세 조회 성공 테스트를 시작합니다."); - Long boardId = 999L; + // given + Long boardId = 2L; + LocalDateTime time = LocalDateTime.now(); + + User user = mock(User.class); + given(user.getNickname()).willReturn("닉네임"); + + Board board = Board.builder() + .boardId(boardId) + .user(user) + .boardTitle("제목") + .boardContent("내용") + .viewCount(10) + .boardCreatedAt(time) + .modifiedAt(null) + .boardStatus(BoardStatus.PUBLISHED) + .boardType(BoardType.FREE) + .build(); + given(boardRepository.findById(boardId)).willReturn(Optional.of(board)); + given(imageFileRepository.findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId)) + .willReturn(Optional.empty()); + + // when + BoardDetailResponse response = boardService.getBoardDetail(boardId); + + // then + assertNotNull(response); + assertNull(response.getImageFileUrl()); + + verify(boardRepository).findById(boardId); + verify(imageFileRepository).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(boardId); + + log.info("이미지가 없는 게시글 상세 조회 성공 테스트가 통과되었습니다."); + } + + /** + * 존재하지 않는 게시글 상세 조회 실패 시나리오를 테스트합니다. + *

+ * 요청한 게시글 ID에 해당하는 게시글이 존재하지 않는 경우, + * {@link BoardErrorCode#BOARD_NOT_FOUND} 예외가 발생하는지 검증합니다. + *

+ */ + @Test + @DisplayName("게시글 조회 실패: 존재하지 않는 게시글이면 예외가 발생한다.") + void getBoardDetail_Fail_BoardNotFound() { + log.info("존재하지 않는 게시글 상세 조회 실패 테스트를 시작합니다."); + + // given + Long boardId = 999L; given(boardRepository.findById(boardId)).willReturn(Optional.empty()); - BoardException ex = assertThrows(BoardException.class, + // when + BoardException exception = assertThrows(BoardException.class, () -> boardService.getBoardDetail(boardId)); - assertEquals(BoardErrorCode.BOARD_NOT_FOUND, ex.getErrorCode()); + // then + assertEquals(BoardErrorCode.BOARD_NOT_FOUND, exception.getErrorCode()); + + verify(boardRepository).findById(boardId); + verify(imageFileRepository, never()).findFirstByBoard_BoardIdOrderByImageFileCreatedAtAsc(anyLong()); + + log.info("존재하지 않는 게시글 상세 조회 실패 테스트가 통과되었습니다."); } } \ No newline at end of file From 96e284cabd4f8315388c43d2f8217c23f6d1ea05 Mon Sep 17 00:00:00 2001 From: Baek HyeonBin <81628455+WhiteBin-bin@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:36:47 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=A8=20Refactor:=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=ED=8C=8C=EC=9D=BC=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionHandler.java | 12 +- .../exception/ImageFileErrorCode.java | 48 +++++ .../exception/ImageFileException.java | 22 +++ .../imagefile/mapper/ImageFileMapper.java | 22 +++ .../imagefile/service/ImageFileService.java | 18 ++ .../service/ImageFileServiceImpl.java | 180 ++++++++++++++---- .../backend/pet/dto/request/PetRequest.java | 6 + .../backend/pet/service/PetServiceImpl.java | 28 ++- .../imagefile/mapper/ImageFileMapper.xml | 21 ++ .../service/ImageFileServiceTest.java | 38 +++- .../backend/pet/service/PetServiceTest.java | 4 + 11 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java create mode 100644 src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java create mode 100644 src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java create mode 100644 src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml diff --git a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java index 5123ca0..f7323bd 100644 --- a/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/dodo/backend/common/exception/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.dodo.backend.board.exception.BoardException; import com.dodo.backend.fence.exception.FenceException; import com.dodo.backend.healthanalysis.exception.HealthAnalysisException; +import com.dodo.backend.imagefile.exception.ImageFileException; import com.dodo.backend.pet.exception.PetException; import com.dodo.backend.petweight.exception.PetWeightException; import com.dodo.backend.reaction.exception.ReactionException; @@ -125,6 +126,15 @@ protected ResponseEntity handleReactionException(ReactionExceptio return toResponseEntity(e.getErrorCode()); } + /** + * 이미지 파일(ImageFile) 도메인 비즈니스 로직에서 발생하는 {@link ImageFileException}을 처리합니다. + */ + @ExceptionHandler(ImageFileException.class) + protected ResponseEntity handleImageFileException(ImageFileException e) { + log.error("ImageFileException occurred: {}", e.getErrorCode()); + return toResponseEntity(e.getErrorCode()); + } + /** * {@code @Valid} 어노테이션을 통한 요청 데이터 유효성 검증 실패 시 발생하는 예외를 처리합니다. */ @@ -164,4 +174,4 @@ protected ResponseEntity handleException(Exception e) { log.error("Internal Server Error : {}", e.getMessage(), e); return toResponseEntity(INTERNAL_SERVER_ERROR); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java new file mode 100644 index 0000000..cc5ad2a --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileErrorCode.java @@ -0,0 +1,48 @@ +package com.dodo.backend.imagefile.exception; + +import com.dodo.backend.common.exception.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +/** + * 이미지 파일(ImageFile) 도메인에서 발생하는 예외 상황을 관리하는 에러 코드 정의 클래스입니다. + *

+ * 이미지 업로드, 프로필 이미지 저장 및 수정 과정에서 발생할 수 있는 예외를 포함하며, + * HTTP 상태 코드와 클라이언트에게 전달할 메시지를 {@code Key-Value} 형태로 관리합니다. + */ +@AllArgsConstructor +@Getter +public enum ImageFileErrorCode implements BaseErrorCode { + + /** + * 클라이언트의 요청 형식이 잘못되었거나 이미지 파일 검증을 통과하지 못했을 때 사용합니다. + *

+ * HTTP {@code 400 Bad Request}를 반환합니다. + */ + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + + /** + * 이미지 업로드 결과에서 유효한 이미지 URL을 확인할 수 없을 때 사용합니다. + *

+ * HTTP {@code 500 Internal Server Error}를 반환합니다. + */ + IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이미지 업로드 중 오류가 발생했습니다."), + + /** + * 서버 내부에서 예상치 못한 오류가 발생했을 때 사용합니다. + *

+ * HTTP {@code 500 Internal Server Error}를 반환합니다. + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + /** + * 에러 상황에 해당하는 HTTP 상태 코드입니다. + */ + private final HttpStatus httpStatus; + + /** + * 클라이언트에게 전달할 상세 에러 메시지입니다. + */ + private final String message; +} diff --git a/src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java new file mode 100644 index 0000000..3d0485b --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/exception/ImageFileException.java @@ -0,0 +1,22 @@ +package com.dodo.backend.imagefile.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 이미지 파일(ImageFile) 도메인 비즈니스 로직 수행 중 발생하는 전용 예외 클래스입니다. + *

+ * 이미지 업로드, 프로필 이미지 저장 및 수정 등 이미지 파일 관련 서비스 로직에서 + * 예외 상황 발생 시 이 클래스를 throw 합니다. + * {@code GlobalExceptionHandler}에서 이 예외를 가로채어 + * {@link ImageFileErrorCode}에 정의된 표준 응답으로 변환합니다. + */ +@AllArgsConstructor +@Getter +public class ImageFileException extends RuntimeException { + + /** + * 발생한 예외의 구체적인 종류(상태 코드, 메시지)를 담고 있는 Enum입니다. + */ + private final ImageFileErrorCode errorCode; +} diff --git a/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java b/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java new file mode 100644 index 0000000..c100fc5 --- /dev/null +++ b/src/main/java/com/dodo/backend/imagefile/mapper/ImageFileMapper.java @@ -0,0 +1,22 @@ +package com.dodo.backend.imagefile.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ImageFileMapper { + + /** + * 펫 프로필 이미지 URL을 수정합니다. + * + * @param petId 수정할 펫 ID + * @param imageFileUrl 새로운 이미지 URL + * @param originalFilename 이미지 URL에서 추출한 파일명 + * @return 수정된 행 수 + */ + int updatePetProfileImage( + @Param("petId") Long petId, + @Param("imageFileUrl") String imageFileUrl, + @Param("originalFilename") String originalFilename + ); +} diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java index c0fc2ec..596081e 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileService.java @@ -1,6 +1,7 @@ package com.dodo.backend.imagefile.service; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; +import com.dodo.backend.pet.entity.Pet; import org.springframework.web.multipart.MultipartFile; import java.util.List; @@ -26,4 +27,21 @@ public interface ImageFileService { * @return 업로드 응답 DTO */ ImageUploadResponse uploadImages(List files); + + /** + * 펫 프로필 이미지 URL을 저장합니다. + * + * @param pet 이미지와 연결할 펫 + * @param imageFileUrl 저장할 이미지 URL + */ + void savePetProfileImage(Pet pet, String imageFileUrl); + + /** + * 펫 프로필 이미지 URL을 수정합니다. + * 기존 이미지 정보가 없으면 새로 저장합니다. + * + * @param pet 이미지와 연결할 펫 + * @param imageFileUrl 수정할 이미지 URL + */ + void updatePetProfileImage(Pet pet, String imageFileUrl); } diff --git a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java index 5f2e7a3..f29e685 100644 --- a/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java +++ b/src/main/java/com/dodo/backend/imagefile/service/ImageFileServiceImpl.java @@ -4,13 +4,16 @@ import com.cloudinary.utils.ObjectUtils; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.exception.ImageFileException; +import com.dodo.backend.imagefile.mapper.ImageFileMapper; import com.dodo.backend.imagefile.repository.ImageFileRepository; -import com.dodo.backend.user.exception.UserException; +import com.dodo.backend.pet.entity.Pet; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.tika.Tika; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.function.ThrowingFunction; import org.springframework.web.multipart.MultipartFile; import java.util.Collections; @@ -20,7 +23,8 @@ import java.util.Set; import java.util.stream.Collectors; -import static com.dodo.backend.user.exception.UserErrorCode.INVALID_REQUEST; +import static com.dodo.backend.imagefile.exception.ImageFileErrorCode.IMAGE_UPLOAD_FAILED; +import static com.dodo.backend.imagefile.exception.ImageFileErrorCode.INVALID_REQUEST; /** * {@link ImageFileService}의 구현체입니다. @@ -38,18 +42,18 @@ public class ImageFileServiceImpl implements ImageFileService { ); private final ImageFileRepository imageFileRepository; + private final ImageFileMapper imageFileMapper; private final Cloudinary cloudinary; private final Tika tika = new Tika(); /** - * {@inheritDoc} + * 펫 ID 목록에 해당하는 프로필 이미지 URL을 일괄 조회합니다. *

- * 처리 과정: - *

    - *
  1. 입력된 펫 ID 목록이 비어있으면 빈 Map을 반환합니다.
  2. - *
  3. 리포지토리의 {@code findAllByPet_PetIdIn}을 호출하여 펫 ID 목록에 해당하는 이미지 파일들을 조회합니다.
  4. - *
  5. 조회된 엔티티에서 펫 ID와 이미지 URL을 추출하여 Map으로 변환합니다.
  6. - *
+ * 입력된 펫 ID 목록이 비어 있으면 DB를 조회하지 않고 빈 Map을 반환합니다. + * 조회된 이미지 파일은 펫 ID를 key, 이미지 URL을 value로 변환합니다. + * + * @param petIds 이미지 URL을 조회할 펫 ID 목록 + * @return 펫 ID와 이미지 URL을 매핑한 Map */ @Override @Transactional(readOnly = true) @@ -69,62 +73,162 @@ public Map getProfileUrlsByPetIds(List petIds) { } /** - * {@inheritDoc} + * 이미지 파일 목록을 Cloudinary에 업로드하고 업로드된 이미지 URL 목록을 반환합니다. + *

+ * 요청 파일이 비어 있거나 허용되지 않은 MIME 타입이면 잘못된 요청 예외를 발생시키고, + * 정상 파일은 개별 업로드 후 secure URL만 응답 DTO에 담습니다. + * + * @param files 업로드할 이미지 파일 목록 + * @return 업로드 성공 메시지와 이미지 URL 목록 */ @Transactional(readOnly = true) @Override public ImageUploadResponse uploadImages(List files) { if (files == null || files.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } List imageUrls = files.stream() - .map(this::uploadSingleImage) + .map(ThrowingFunction.of( + this::uploadSingleImage, + (message, exception) -> new ImageFileException(IMAGE_UPLOAD_FAILED) + )) .toList(); return ImageUploadResponse.toDto("이미지 업로드에 성공하였습니다.", imageUrls); } - private String uploadSingleImage(MultipartFile file) { + /** + * 펫 프로필 이미지 URL을 {@code image_file} 테이블에 신규 저장합니다. + *

+ * 이미지 URL이 비어 있으면 저장하지 않습니다. 파일 크기는 외부 URL만 전달받는 + * 현재 요청 구조상 알 수 없으므로 기본값 {@code 0L}로 저장합니다. + * + * @param pet 이미지와 연결할 펫 엔티티 + * @param imageFileUrl 저장할 이미지 URL + */ + @Transactional + @Override + public void savePetProfileImage(Pet pet, String imageFileUrl) { + if (pet == null || isBlank(imageFileUrl)) { + return; + } + + ImageFile imageFile = ImageFile.builder() + .pet(pet) + .imageFileUrl(imageFileUrl) + .size(0L) + .originalFilename(extractOriginalFilename(imageFileUrl)) + .build(); + + imageFileRepository.save(imageFile); + } + + /** + * 펫 프로필 이미지 URL을 수정합니다. + *

+ * 기존 이미지 행이 있으면 MyBatis mapper로 URL과 원본 파일명을 갱신하고, + * 기존 행이 없으면 신규 이미지 정보로 저장합니다. + * + * @param pet 이미지와 연결된 펫 엔티티 + * @param imageFileUrl 수정할 이미지 URL + */ + @Transactional + @Override + public void updatePetProfileImage(Pet pet, String imageFileUrl) { + if (pet == null || pet.getPetId() == null || isBlank(imageFileUrl)) { + return; + } + + int updatedRows = imageFileMapper.updatePetProfileImage( + pet.getPetId(), + imageFileUrl, + extractOriginalFilename(imageFileUrl) + ); + + if (updatedRows == 0) { + savePetProfileImage(pet, imageFileUrl); + } + } + + /** + * 단일 이미지 파일을 검증한 뒤 Cloudinary에 업로드합니다. + *

+ * 파일이 비어 있거나 허용되지 않은 MIME 타입이면 이미지 파일 도메인 예외를 발생시키고, + * 업로드 결과에 secure URL이 없으면 업로드 실패 예외를 발생시킵니다. + * + * @param file 업로드할 이미지 파일 + * @return Cloudinary에서 반환한 secure URL + * @throws Exception 파일 바이트 조회 또는 Cloudinary 업로드 중 checked exception이 발생한 경우 + */ + private String uploadSingleImage(MultipartFile file) throws Exception { if (file == null || file.isEmpty()) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } String contentType = file.getContentType(); if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } String detectedMimeType = detectMimeType(file); if (!ALLOWED_MIME_TYPES.contains(detectedMimeType)) { - throw new UserException(INVALID_REQUEST); + throw new ImageFileException(INVALID_REQUEST); } - try { - Map uploadResult = cloudinary.uploader().upload( - file.getBytes(), - ObjectUtils.asMap( - "folder", "images", - "resource_type", "image" - ) - ); - Object url = uploadResult.get("secure_url"); - if (url == null) { - throw new IllegalStateException("Cloudinary 응답에 secure_url이 없습니다."); - } - return Objects.toString(url); - } catch (UserException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException("이미지 업로드 중 오류가 발생했습니다.", e); + Map uploadResult = cloudinary.uploader().upload( + file.getBytes(), + ObjectUtils.asMap( + "folder", "images", + "resource_type", "image" + ) + ); + Object url = uploadResult.get("secure_url"); + if (url == null) { + throw new ImageFileException(IMAGE_UPLOAD_FAILED); } + return Objects.toString(url); + } + + /** + * 파일 바이트를 기반으로 실제 MIME 타입을 감지합니다. + * + * @param file MIME 타입을 확인할 이미지 파일 + * @return 감지된 MIME 타입 + * @throws Exception 파일 바이트 조회 또는 MIME 타입 감지 중 checked exception이 발생한 경우 + */ + private String detectMimeType(MultipartFile file) throws Exception { + return tika.detect(file.getBytes()); } - private String detectMimeType(MultipartFile file) { - try { - return tika.detect(file.getBytes()); - } catch (Exception e) { - throw new UserException(INVALID_REQUEST); + /** + * 문자열이 null이거나 공백 문자열인지 확인합니다. + * + * @param value 확인할 문자열 + * @return null 또는 공백 문자열이면 true + */ + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /** + * 이미지 URL에서 원본 파일명을 추출합니다. + *

+ * 쿼리 파라미터는 제거하고 마지막 경로 구간을 파일명으로 사용합니다. + * 추출할 수 없으면 기본 파일명을 반환합니다. + * + * @param imageFileUrl 파일명을 추출할 이미지 URL + * @return 추출된 파일명 또는 기본 파일명 + */ + private String extractOriginalFilename(String imageFileUrl) { + int queryStart = imageFileUrl.indexOf('?'); + String urlWithoutQuery = queryStart >= 0 ? imageFileUrl.substring(0, queryStart) : imageFileUrl; + int slashIndex = urlWithoutQuery.lastIndexOf('/'); + + if (slashIndex < 0 || slashIndex == urlWithoutQuery.length() - 1) { + return "pet_profile_image"; } + + return urlWithoutQuery.substring(slashIndex + 1); } } diff --git a/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java b/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java index ba6d1d2..9c26032 100644 --- a/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java +++ b/src/main/java/com/dodo/backend/pet/dto/request/PetRequest.java @@ -36,6 +36,9 @@ public static class PetRegisterRequest { @Schema(description = "반려동물 등록번호 (선택사항, 없을 시 null)", example = "null", nullable = true) private String registrationNumber; + @Schema(description = "반려동물 프로필 이미지 URL", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + @Schema(description = "성별 (MALE, FEMALE, NEUTER)", example = "MALE") @NotNull(message = "성별은 필수입니다.") private String sex; @@ -104,6 +107,9 @@ public static class PetUpdateRequest { @Size(max = 15, message = "등록번호는 15자 이하여야 합니다.") private String registrationNumber; + @Schema(description = "변경할 반려동물 프로필 이미지 URL (선택)", example = "https://example.com/images/bori.jpg") + private String imageFileUrl; + @Schema(description = "변경할 성별 (선택, MALE, FEMALE, NEUTER)", example = "FEMALE") private String sex; diff --git a/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java b/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java index 28e3340..9892385 100644 --- a/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java +++ b/src/main/java/com/dodo/backend/pet/service/PetServiceImpl.java @@ -95,6 +95,8 @@ public PetRegisterResponse registerPet(UUID userId, PetRegisterRequest request) Pet pet = request.toEntity(); Pet savedPet = petRepository.save(pet); + imageFileService.savePetProfileImage(savedPet, request.getImageFileUrl()); + userPetService.registerUserPet(userId, savedPet, RegistrationStatus.APPROVED); log.info("펫 등록 및 유저 관계 설정 완료 - User: {}, PetId: {}", userId, savedPet.getPetId()); @@ -136,7 +138,10 @@ public PetUpdateResponse updatePet(Long petId, PetUpdateRequest request, UUID us } } - petMapper.updatePetProfileInfo(request, petId); + if (hasPetTableUpdateFields(request)) { + petMapper.updatePetProfileInfo(request, petId); + } + imageFileService.updatePetProfileImage(pet, request.getImageFileUrl()); log.info("반려동물 프로필 수정 성공 - PetId: {}", petId); @@ -540,6 +545,27 @@ public PetDeviceCheckResponse checkDeviceIdAvailability(PetDeviceCheckRequest re return PetDeviceCheckResponse.toDto("사용 가능한 디바이스 ID입니다.", true); } + /** + * {@code pet} 테이블에 반영할 필드가 있는지 확인합니다. + *

+ * {@code imageFileUrl}은 {@code image_file} 도메인에서 별도로 처리하므로 + * 이 검사 대상에 포함하지 않습니다. 이미지 URL만 수정하는 요청에서 + * {@link PetMapper#updatePetProfileInfo}를 호출하면 MyBatis의 동적 + * {@code }에 들어갈 컬럼이 없어 잘못된 SQL이 생성될 수 있습니다. + * + * @param request 반려동물 수정 요청 DTO + * @return {@code pet} 테이블 업데이트 대상 필드가 하나 이상 있으면 true + */ + private boolean hasPetTableUpdateFields(PetUpdateRequest request) { + return request.getRegistrationNumber() != null + || request.getSex() != null + || request.getAge() != null + || request.getPetName() != null + || request.getBreed() != null + || request.getReferenceHeartRate() != null + || request.getDeviceId() != null; + } + /** * 반려동물 특이사항을 생성합니다. *

diff --git a/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml b/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml new file mode 100644 index 0000000..d10e9c3 --- /dev/null +++ b/src/main/resources/com/dodo/backend/imagefile/mapper/ImageFileMapper.xml @@ -0,0 +1,21 @@ + + + + + + + + + UPDATE image_file + + image_file_url = #{imageFileUrl}, + original_filename = #{originalFilename} + + WHERE pet_id = #{petId} + + diff --git a/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java b/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java index 33c9feb..d383c15 100644 --- a/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java +++ b/src/test/java/com/dodo/backend/imagefile/service/ImageFileServiceTest.java @@ -4,9 +4,10 @@ import com.cloudinary.Uploader; import com.dodo.backend.imagefile.dto.response.ImageFileResponse.ImageUploadResponse; import com.dodo.backend.imagefile.entity.ImageFile; +import com.dodo.backend.imagefile.exception.ImageFileException; +import com.dodo.backend.imagefile.mapper.ImageFileMapper; import com.dodo.backend.imagefile.repository.ImageFileRepository; import com.dodo.backend.pet.entity.Pet; -import com.dodo.backend.user.exception.UserException; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,6 +47,9 @@ class ImageFileServiceTest { @Mock private ImageFileRepository imageFileRepository; + @Mock + private ImageFileMapper imageFileMapper; + @Mock private Cloudinary cloudinary; @@ -123,6 +127,36 @@ void getProfileUrlsByPetIds_EmptyInput() { log.info("테스트 종료: 프로필 이미지 조회 (빈 리스트)"); } + @Test + @DisplayName("펫 프로필 이미지 저장 성공: 이미지 URL을 ImageFile 엔티티로 저장한다.") + void savePetProfileImage_Success() { + // given + Pet pet = Pet.builder().build(); + String imageFileUrl = "https://example.com/images/bori.jpg"; + + // when + imageFileService.savePetProfileImage(pet, imageFileUrl); + + // then + verify(imageFileRepository).save(any(ImageFile.class)); + } + + @Test + @DisplayName("펫 프로필 이미지 수정 성공: 기존 이미지 URL을 mapper로 수정한다.") + void updatePetProfileImage_Success() { + // given + Pet pet = Pet.builder().petId(1L).build(); + String imageFileUrl = "https://example.com/images/bori.jpg"; + given(imageFileMapper.updatePetProfileImage(1L, imageFileUrl, "bori.jpg")).willReturn(1); + + // when + imageFileService.updatePetProfileImage(pet, imageFileUrl); + + // then + verify(imageFileMapper).updatePetProfileImage(1L, imageFileUrl, "bori.jpg"); + verify(imageFileRepository, never()).save(any(ImageFile.class)); + } + @Test @DisplayName("이미지 업로드 성공: 파일 목록 업로드 후 URL 목록을 반환한다.") void uploadImages_Success() throws Exception { @@ -153,7 +187,7 @@ void uploadImages_Fail_InvalidFileType() { MockMultipartFile file = new MockMultipartFile("files", "a.txt", "text/plain", "text".getBytes()); // when - UserException exception = assertThrows(UserException.class, () -> + ImageFileException exception = assertThrows(ImageFileException.class, () -> imageFileService.uploadImages(List.of(file)) ); diff --git a/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java b/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java index 81613fb..ed00f88 100644 --- a/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java +++ b/src/test/java/com/dodo/backend/pet/service/PetServiceTest.java @@ -90,6 +90,7 @@ void registerPet_Success() { .age(3) .birth(LocalDateTime.now()) .registrationNumber("1234567890") + .imageFileUrl("https://example.com/images/bori.jpg") .sex("MALE") .deviceId("DEV_123") .build(); @@ -113,6 +114,7 @@ void registerPet_Success() { verify(userPetService, times(1)).existsUser(userId); verify(petRepository, times(1)).save(any(Pet.class)); + verify(imageFileService, times(1)).savePetProfileImage(savedPet, request.getImageFileUrl()); verify(userPetService, times(1)).registerUserPet(userId, savedPet, RegistrationStatus.APPROVED); log.info("펫 등록 성공 테스트가 통과되었습니다."); } @@ -225,6 +227,7 @@ void updatePet_Success() { PetRequest.PetUpdateRequest request = PetRequest.PetUpdateRequest.builder() .petName("새로운초코") .registrationNumber("NEW-999") + .imageFileUrl("https://example.com/images/choco.jpg") .sex("FEMALE") .age(5) .build(); @@ -244,6 +247,7 @@ void updatePet_Success() { assertEquals("새로운초코", response.getPetName()); verify(petMapper, times(1)).updatePetProfileInfo(request, petId); + verify(imageFileService, times(1)).updatePetProfileImage(existingPet, request.getImageFileUrl()); log.info("펫 수정 성공 테스트가 통과되었습니다."); }