From 5c15d4ade790a24ccbdc44082b0969f3b4b35e91 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:14:26 +0900 Subject: [PATCH 1/9] =?UTF-8?q?Feat:=20=EB=82=B4=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/post/dto/PostListResponse.java | 18 +++++++++++++ .../back/domain/board/post/entity/Post.java | 1 + .../board/post/repository/PostRepository.java | 3 +++ .../user/controller/UserController.java | 21 ++++++++++++++- .../back/domain/user/service/UserService.java | 26 +++++++++++++++++++ 5 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java index 20a5779a..338c6f4d 100644 --- a/src/main/java/com/back/domain/board/post/dto/PostListResponse.java +++ b/src/main/java/com/back/domain/board/post/dto/PostListResponse.java @@ -1,6 +1,7 @@ package com.back.domain.board.post.dto; import com.back.domain.board.common.dto.AuthorResponse; +import com.back.domain.board.post.entity.Post; import com.querydsl.core.annotations.QueryProjection; import lombok.Getter; import lombok.Setter; @@ -49,4 +50,21 @@ public PostListResponse(Long postId, this.createdAt = createdAt; this.updatedAt = updatedAt; } + + public static PostListResponse from(Post post) { + return new PostListResponse( + post.getId(), + AuthorResponse.from(post.getUser()), + post.getTitle(), + post.getThumbnailUrl(), + post.getCategories().stream() + .map(CategoryResponse::from) + .toList(), + post.getLikeCount(), + post.getBookmarkCount(), + post.getCommentCount(), + post.getCreatedAt(), + post.getUpdatedAt() + ); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/post/entity/Post.java b/src/main/java/com/back/domain/board/post/entity/Post.java index 30c34d02..1365c631 100644 --- a/src/main/java/com/back/domain/board/post/entity/Post.java +++ b/src/main/java/com/back/domain/board/post/entity/Post.java @@ -93,6 +93,7 @@ public void update(String title, String content) { this.content = content; } + // TODO: 진짜로 바뀐 카테고리만 추가/삭제하도록 개선 /** 카테고리 일괄 업데이트 */ public void updateCategories(List categories) { this.postCategoryMappings.clear(); diff --git a/src/main/java/com/back/domain/board/post/repository/PostRepository.java b/src/main/java/com/back/domain/board/post/repository/PostRepository.java index 7aafead2..9d338f4f 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostRepository.java @@ -2,9 +2,12 @@ import com.back.domain.board.post.entity.Post; import com.back.domain.board.post.repository.custom.PostRepositoryCustom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface PostRepository extends JpaRepository, PostRepositoryCustom { + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index 5a1f6a86..ec11c5ab 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -1,5 +1,7 @@ package com.back.domain.user.controller; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -8,6 +10,9 @@ import com.back.global.security.user.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -20,7 +25,7 @@ public class UserController implements UserControllerDocs { // 내 정보 조회 @GetMapping("/me") - public ResponseEntity> getMyInfo ( + public ResponseEntity> getMyInfo( @AuthenticationPrincipal CustomUserDetails user ) { UserDetailResponse userDetail = userService.getUserInfo(user.getUserId()); @@ -69,4 +74,18 @@ public ResponseEntity> deleteMyAccount( "회원 탈퇴가 완료되었습니다." )); } + + // 내 게시글 목록 조회 + @GetMapping("/me/posts") + public ResponseEntity>> getMyPosts( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyPosts(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 게시글 목록이 조회되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 8568655c..60646bde 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,5 +1,8 @@ package com.back.domain.user.service; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -12,16 +15,21 @@ import com.back.global.exception.ErrorCode; import com.back.global.util.PasswordValidator; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; + private final PostRepository postRepository; private final PasswordEncoder passwordEncoder; /** @@ -124,6 +132,24 @@ public void deleteUser(Long userId) { } } + /** + * 내 게시글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 게시글 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyPosts(Long userId, Pageable pageable) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 게시글 목록 조회 및 응답 반환 + Page page = postRepository.findAllByUserId(userId, pageable) + .map(PostListResponse::from); + return PageResponse.from(page); + } + /** * 유효한 사용자 조회 및 상태 검증 * From b59e9d18eea055101a4b9080318bda16219644e6 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:34:34 +0900 Subject: [PATCH 2/9] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 143 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 82 +++++++++- 2 files changed, 224 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index 0fc67bfb..1b1c596b 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -1,5 +1,9 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentRepository; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.entity.User; @@ -21,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -41,6 +46,9 @@ class UserControllerTest { @Autowired private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + @Autowired private TestJwtTokenProvider testJwtTokenProvider; @@ -652,4 +660,139 @@ void deleteMyAccount_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_004")) .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + +// ====================== 내 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 게시글 목록 조회 성공 → 200 OK") + void getMyPosts_success() throws Exception { + // given: 정상 유저 + 게시글 2개 생성 + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post1 = new Post(user, "첫 번째 글", "내용1", null); + Post post2 = new Post(user, "두 번째 글", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ).andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 게시글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].title").value("두 번째 글")) // 최신순(createdAt desc) + .andExpect(jsonPath("$.data.items[1].title").value("첫 번째 글")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyPosts_userNotFound() throws Exception { + // given: 존재하지 않는 ID로 JWT 발급 + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform( + get("/api/users/me/posts") + .header("Authorization", "Bearer " + fakeToken) + ) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyPosts_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyPosts_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyPosts_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/posts")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyPosts_invalidAccessToken() throws Exception { + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyPosts_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/posts") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } } diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index a4ead658..61729025 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -1,5 +1,9 @@ package com.back.domain.user.service; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; +import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -14,11 +18,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -35,7 +43,7 @@ class UserServiceTest { private UserRepository userRepository; @Autowired - private UserProfileRepository userProfileRepository; + private PostRepository postRepository; @Autowired private PasswordEncoder passwordEncoder; @@ -365,4 +373,76 @@ void deleteUser_notFound() { .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); } + // ====================== 내 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 게시글 목록 조회 성공") + void getMyPosts_success() { + // given + User user = User.createUser("writer", "writer@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 게시글 2개 작성 + Post post1 = new Post(user, "제목1", "내용1", null); + Post post2 = new Post(user, "제목2", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyPosts(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("제목2"); // 최신순 정렬 + assertThat(response.items().get(1).getTitle()).isEqualTo("제목1"); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID → USER_NOT_FOUND 예외 발생") + void getMyPosts_userNotFound() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyPosts_deletedUser() { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyPosts_suspendedUser() { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyPosts(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } } From a95cf0e7702b87c65dadb05549f7532708353c4d Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 16:36:23 +0900 Subject: [PATCH 3/9] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerDocs.java | 330 ++++++++++++++---- 1 file changed, 253 insertions(+), 77 deletions(-) diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index a282d385..1043e419 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -1,5 +1,7 @@ package com.back.domain.user.controller; +import com.back.domain.board.common.dto.PageResponse; +import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; import com.back.domain.user.dto.UserDetailResponse; @@ -12,6 +14,8 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; @@ -317,13 +321,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": true, - "code": "SUCCESS_200", - "message": "비밀번호가 변경되었습니다.", - "data": null - } - """) + { + "success": true, + "code": "SUCCESS_200", + "message": "비밀번호가 변경되었습니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -332,13 +336,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_005", - "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_005", + "message": "비밀번호는 최소 8자 이상, 숫자/특수문자를 포함해야 합니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -347,13 +351,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_010", - "message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_010", + "message": "소셜 로그인 회원은 비밀번호를 변경할 수 없습니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -362,13 +366,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_008", - "message": "정지된 계정입니다. 관리자에게 문의하세요.", - "data": null - } - """) + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) ) ), @ApiResponse( @@ -377,13 +381,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_009", - "message": "탈퇴한 계정입니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -393,37 +397,37 @@ ResponseEntity> updateMyProfile( mediaType = "application/json", examples = { @ExampleObject(name = "토큰 없음", value = """ - { - "success": false, - "code": "AUTH_001", - "message": "인증이 필요합니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), @ExampleObject(name = "잘못된 토큰", value = """ - { - "success": false, - "code": "AUTH_002", - "message": "유효하지 않은 액세스 토큰입니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), @ExampleObject(name = "만료된 토큰", value = """ - { - "success": false, - "code": "AUTH_004", - "message": "만료된 액세스 토큰입니다.", - "data": null - } - """), + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """), @ExampleObject(name = "현재 비밀번호 불일치", value = """ - { - "success": false, - "code": "USER_006", - "message": "아이디 또는 비밀번호가 올바르지 않습니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_006", + "message": "아이디 또는 비밀번호가 올바르지 않습니다.", + "data": null + } + """) } ) ), @@ -433,13 +437,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "USER_001", - "message": "존재하지 않는 사용자입니다.", - "data": null - } - """) + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) ) ), @ApiResponse( @@ -448,13 +452,13 @@ ResponseEntity> updateMyProfile( content = @Content( mediaType = "application/json", examples = @ExampleObject(value = """ - { - "success": false, - "code": "COMMON_500", - "message": "서버 오류가 발생했습니다.", - "data": null - } - """) + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) ) ) }) @@ -581,4 +585,176 @@ ResponseEntity> changeMyPassword( ResponseEntity> deleteMyAccount( @AuthenticationPrincipal CustomUserDetails user ); + + @Operation( + summary = "내 게시글 목록 조회", + description = """ + 로그인한 사용자가 작성한 게시글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내 게시글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "내 게시글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "postId": 1, + "author": { "id": 10, "nickname": "홍길동", "profileImageUrl": null }, + "title": "첫 글", + "thumbnailUrl": null, + "categories": [ + { "id": 1, "name": "프론트엔드", "type": "SUBJECT" } + ], + "likeCount": 5, + "bookmarkCount": 2, + "commentCount": 3, + "createdAt": "2025-09-30T10:15:30", + "updatedAt": "2025-09-30T10:20:00" + }, + { + "postId": 2, + "author": { "id": 10, "nickname": "홍길동", "profileImageUrl": null }, + "title": "두 번째 글", + "thumbnailUrl": null, + "categories": [], + "likeCount": 0, + "bookmarkCount": 0, + "commentCount": 1, + "createdAt": "2025-09-29T14:00:00", + "updatedAt": "2025-09-29T14:10:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 25, + "totalPages": 3, + "last": false + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청(파라미터 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getMyPosts( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); } From e97cdef2bc07f6153d7d6e1eba09d477a16e6ef6 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:02:30 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Feat:=20=EB=82=B4=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/comment/dto/MyCommentResponse.java | 55 +++++++++++++++++++ .../comment/repository/CommentRepository.java | 3 + .../user/controller/UserController.java | 15 +++++ .../back/domain/user/service/UserService.java | 21 +++++++ 4 files changed, 94 insertions(+) create mode 100644 src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java diff --git a/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java new file mode 100644 index 00000000..1e697459 --- /dev/null +++ b/src/main/java/com/back/domain/board/comment/dto/MyCommentResponse.java @@ -0,0 +1,55 @@ +package com.back.domain.board.comment.dto; + +import com.back.domain.board.comment.entity.Comment; + +import java.time.LocalDateTime; + +/** + * 내 댓글 목록 응답 DTO + * + * @param commentId 댓글 ID + * @param postId 게시글 ID + * @param postTitle 게시글 제목 + * @param parentId 부모 댓글 ID + * @param parentContent 부모 댓글 내용 (50자) + * @param content 댓글 내용 + * @param likeCount 좋아요 수 + * @param createdAt 댓글 생성 일시 + * @param updatedAt 댓글 수정 일시 + */ +public record MyCommentResponse( + Long commentId, + Long postId, + String postTitle, + Long parentId, + String parentContent, + String content, + long likeCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static MyCommentResponse from(Comment comment) { + return new MyCommentResponse( + comment.getId(), + comment.getPost().getId(), + comment.getPost().getTitle(), + comment.getParent() != null + ? comment.getParent().getId() + : null, + comment.getParent() != null + ? truncate(comment.getParent().getContent()) + : null, + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } + + private static String truncate(String content) { + int length = 50; + return (content == null || content.length() <= length) + ? content + : content.substring(0, length) + "..."; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java index 72004816..2b008a16 100644 --- a/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/board/comment/repository/CommentRepository.java @@ -2,9 +2,12 @@ import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.repository.custom.CommentRepositoryCustom; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index ec11c5ab..64c760f4 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.dto.MyCommentResponse; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; @@ -88,4 +89,18 @@ public ResponseEntity>> getMyPosts( response )); } + + // 내 댓글 목록 조회 + @GetMapping("/me/comments") + public ResponseEntity>> getMyComments( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyComments(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 댓글 목록이 조회되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 60646bde..72b130ab 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -1,5 +1,7 @@ package com.back.domain.user.service; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.repository.PostRepository; @@ -30,6 +32,7 @@ public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; private final PostRepository postRepository; + private final CommentRepository commentRepository; private final PasswordEncoder passwordEncoder; /** @@ -150,6 +153,24 @@ public PageResponse getMyPosts(Long userId, Pageable pageable) return PageResponse.from(page); } + /** + * 내 댓글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 댓글 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyComments(Long userId, Pageable pageable) { + + // 사용자 조회 및 상태 검증 + User user = getValidUser(userId); + + // 댓글 목록 조회 및 응답 반환 + Page page = commentRepository.findAllByUserId(user.getId(), pageable) + .map(MyCommentResponse::from); + return PageResponse.from(page); + } + /** * 유효한 사용자 조회 및 상태 검증 * From d93d130b152cadf665370cb5d3b0704069567f5e Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:05:54 +0900 Subject: [PATCH 5/9] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 144 +++++++++++++++++- .../domain/user/service/UserServiceTest.java | 83 ++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index 1b1c596b..c069ab18 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -49,6 +49,9 @@ class UserControllerTest { @Autowired private PostRepository postRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired private TestJwtTokenProvider testJwtTokenProvider; @@ -661,7 +664,7 @@ void deleteMyAccount_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } -// ====================== 내 게시글 목록 조회 테스트 ====================== + // ====================== 내 게시글 목록 조회 테스트 ====================== @Test @DisplayName("내 게시글 목록 조회 성공 → 200 OK") @@ -795,4 +798,143 @@ void getMyPosts_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_004")) .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + + // ====================== 내 댓글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 댓글 목록 조회 성공 → 200 OK") + void getMyComments_success() throws Exception { + // given: 정상 유저 + 게시글 + 댓글 2개 생성 + User user = User.createUser("commenter", "commenter@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post = new Post(user, "스프링 트랜잭션 정리", "내용입니다.", null); + postRepository.save(post); + + Comment parent = new Comment(post, user, "코딩 박사의 스프링 교재도 추천합니다.", null); + Comment comment1 = new Comment(post, user, "정말 도움이 많이 됐어요!", null); + Comment comment2 = new Comment(post, user, "감사합니다! 더 공부해볼게요.", parent); + commentRepository.saveAll(List.of(parent, comment1, comment2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ).andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 댓글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(3)) + .andExpect(jsonPath("$.data.items[0].content").value("감사합니다! 더 공부해볼게요.")) + .andExpect(jsonPath("$.data.items[1].content").value("정말 도움이 많이 됐어요!")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyComments_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + fakeToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyComments_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyComments_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyComments_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/comments")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyComments_invalidAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyComments_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/comments") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } } diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 61729025..9a8dcfda 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -1,5 +1,8 @@ package com.back.domain.user.service; +import com.back.domain.board.comment.dto.MyCommentResponse; +import com.back.domain.board.comment.entity.Comment; +import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.entity.Post; @@ -45,6 +48,9 @@ class UserServiceTest { @Autowired private PostRepository postRepository; + @Autowired + private CommentRepository commentRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -445,4 +451,81 @@ void getMyPosts_suspendedUser() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); } + + // ====================== 내 댓글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 댓글 목록 조회 성공") + void getMyComments_success() { + // given + User user = User.createUser("commenter", "commenter@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + // 게시글 하나 생성 + Post post = new Post(user, "테스트 게시글", "게시글 내용", null); + postRepository.save(post); + + // 댓글 2개 작성 + Comment comment1 = new Comment(post, user, "첫 번째 댓글", null); + Comment comment2 = new Comment(post, user, "두 번째 댓글", null); + commentRepository.saveAll(List.of(comment1, comment2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyComments(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).content()).isEqualTo("두 번째 댓글"); // 최신순 정렬 + assertThat(response.items().get(1).content()).isEqualTo("첫 번째 댓글"); + } + + @Test + @DisplayName("존재하지 않는 사용자 ID → USER_NOT_FOUND 예외 발생") + void getMyComments_userNotFound() { + // given + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyComments_deletedUser() { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyComments_suspendedUser() { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + + // when & then + assertThatThrownBy(() -> userService.getMyComments(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } } From 3e9518829d3b320d168411bc0cd9687b9445a8fc Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:06:32 +0900 Subject: [PATCH 6/9] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerDocs.java | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index 1043e419..28c5d927 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.board.comment.dto.MyCommentResponse; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.user.dto.ChangePasswordRequest; @@ -757,4 +758,172 @@ ResponseEntity>> getMyPosts( @AuthenticationPrincipal CustomUserDetails user, @ParameterObject Pageable pageable ); + + @Operation( + summary = "내 댓글 목록 조회", + description = """ + 로그인한 사용자가 작성한 댓글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내 댓글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "내 댓글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "commentId": 12, + "postId": 5, + "postTitle": "스프링 트랜잭션 정리", + "parentId": null, + "parentContent": null, + "content": "정말 도움이 많이 됐어요!", + "likeCount": 3, + "createdAt": "2025-09-29T12:15:00", + "updatedAt": "2025-09-29T12:30:00" + }, + { + "commentId": 14, + "postId": 5, + "postTitle": "스프링 트랜잭션 정리", + "parentId": 13, + "parentContent": "코딩 박사의 스프링 교재도 추천합니다.", + "content": "감사합니다! 더 공부해볼게요.", + "likeCount": 1, + "createdAt": "2025-09-29T12:45:00", + "updatedAt": "2025-09-29T12:45:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 2, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청(파라미터 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getMyComments( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); } From d372b3d819eefc953cb6b4fb751bf56dc1a2b461 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:16:39 +0900 Subject: [PATCH 7/9] =?UTF-8?q?Feat:=20=EB=82=B4=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/PostBookmarkRepository.java | 3 ++ .../user/controller/UserController.java | 14 ++++++++ .../back/domain/user/service/UserService.java | 33 +++++++++++++++++-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java index b26b2a77..ba8c2469 100644 --- a/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java +++ b/src/main/java/com/back/domain/board/post/repository/PostBookmarkRepository.java @@ -1,6 +1,8 @@ package com.back.domain.board.post.repository; import com.back.domain.board.post.entity.PostBookmark; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,4 +12,5 @@ public interface PostBookmarkRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); Optional findByUserIdAndPostId(Long userId, Long postId); + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/user/controller/UserController.java b/src/main/java/com/back/domain/user/controller/UserController.java index 64c760f4..79abf699 100644 --- a/src/main/java/com/back/domain/user/controller/UserController.java +++ b/src/main/java/com/back/domain/user/controller/UserController.java @@ -103,4 +103,18 @@ public ResponseEntity>> getMyComments( response )); } + + // 내 북마크 게시글 목록 조회 + @GetMapping("/me/bookmarks") + public ResponseEntity>> getMyBookmarks( + @AuthenticationPrincipal CustomUserDetails user, + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse response = userService.getMyBookmarks(user.getUserId(), pageable); + return ResponseEntity + .ok(RsData.success( + "내 북마크 게시글 목록이 조회되었습니다.", + response + )); + } } diff --git a/src/main/java/com/back/domain/user/service/UserService.java b/src/main/java/com/back/domain/user/service/UserService.java index 72b130ab..33051f45 100644 --- a/src/main/java/com/back/domain/user/service/UserService.java +++ b/src/main/java/com/back/domain/user/service/UserService.java @@ -4,6 +4,7 @@ import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; +import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; @@ -31,8 +32,9 @@ public class UserService { private final UserRepository userRepository; private final UserProfileRepository userProfileRepository; - private final PostRepository postRepository; private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final PostBookmarkRepository postBookmarkRepository; private final PasswordEncoder passwordEncoder; /** @@ -135,6 +137,7 @@ public void deleteUser(Long userId) { } } + // TODO: 내 게시글/댓글/북마크 목록 조회 N+1 발생 가능, 추후 리팩토링 필요 /** * 내 게시글 목록 조회 서비스 * 1. 사용자 조회 및 상태 검증 @@ -147,9 +150,11 @@ public PageResponse getMyPosts(Long userId, Pageable pageable) // 사용자 조회 및 상태 검증 User user = getValidUser(userId); - // 게시글 목록 조회 및 응답 반환 + // 게시글 목록 조회 Page page = postRepository.findAllByUserId(userId, pageable) .map(PostListResponse::from); + + // 페이지 응답 반환 return PageResponse.from(page); } @@ -165,9 +170,31 @@ public PageResponse getMyComments(Long userId, Pageable pagea // 사용자 조회 및 상태 검증 User user = getValidUser(userId); - // 댓글 목록 조회 및 응답 반환 + // 댓글 목록 조회 Page page = commentRepository.findAllByUserId(user.getId(), pageable) .map(MyCommentResponse::from); + + // 페이지 응답 반환 + return PageResponse.from(page); + } + + /** + * 내 북마크 게시글 목록 조회 서비스 + * 1. 사용자 조회 및 상태 검증 + * 2. 북마크 목록 조회 + * 3. PageResponse 반환 + */ + @Transactional(readOnly = true) + public PageResponse getMyBookmarks(Long userId, Pageable pageable) { + + // 사용자 검증 + User user = getValidUser(userId); + + // 북마크된 게시글 조회 + Page page = postBookmarkRepository.findAllByUserId(user.getId(), pageable) + .map(bookmark -> PostListResponse.from(bookmark.getPost())); + + // 페이지 응답 반환 return PageResponse.from(page); } From dffe9049ad83d293c2129e3a67ec35fab5ee8dd3 Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:22:05 +0900 Subject: [PATCH 8/9] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerTest.java | 144 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 70 +++++++++ 2 files changed, 214 insertions(+) diff --git a/src/test/java/com/back/domain/user/controller/UserControllerTest.java b/src/test/java/com/back/domain/user/controller/UserControllerTest.java index c069ab18..af50fed8 100644 --- a/src/test/java/com/back/domain/user/controller/UserControllerTest.java +++ b/src/test/java/com/back/domain/user/controller/UserControllerTest.java @@ -3,6 +3,8 @@ import com.back.domain.board.comment.entity.Comment; import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; @@ -49,6 +51,9 @@ class UserControllerTest { @Autowired private PostRepository postRepository; + @Autowired + private PostBookmarkRepository postBookmarkRepository; + @Autowired private CommentRepository commentRepository; @@ -937,4 +942,143 @@ void getMyComments_expiredAccessToken() throws Exception { .andExpect(jsonPath("$.code").value("AUTH_004")) .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); } + + // ====================== 내 북마크 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 북마크 게시글 목록 조회 성공 → 200 OK") + void getMyBookmarks_success() throws Exception { + // given + User user = User.createUser("bookmarkUser", "bookmark@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "홍길동", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post1 = new Post(user, "JPA 영속성 전이 완벽 정리", "내용1", null); + Post post2 = new Post(user, "테스트 코드 작성 가이드", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + PostBookmark bookmark1 = new PostBookmark(post1, user); + PostBookmark bookmark2 = new PostBookmark(post2, user); + postBookmarkRepository.saveAll(List.of(bookmark1, bookmark2)); + + String accessToken = generateAccessToken(user); + + // when + ResultActions resultActions = mvc.perform( + get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10") + ) + .andDo(print()); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.code").value("SUCCESS_200")) + .andExpect(jsonPath("$.message").value("내 북마크 게시글 목록이 조회되었습니다.")) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].title").value("테스트 코드 작성 가이드")) + .andExpect(jsonPath("$.data.items[1].title").value("JPA 영속성 전이 완벽 정리")); + } + + @Test + @DisplayName("존재하지 않는 사용자 → 404 Not Found") + void getMyBookmarks_userNotFound() throws Exception { + // given + String fakeToken = testJwtTokenProvider.createAccessToken(999L, "ghost", "USER"); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + fakeToken)) + .andDo(print()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("USER_001")) + .andExpect(jsonPath("$.message").value("존재하지 않는 사용자입니다.")); + } + + @Test + @DisplayName("탈퇴한 계정 → 410 Gone") + void getMyBookmarks_deletedUser() throws Exception { + // given + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isGone()) + .andExpect(jsonPath("$.code").value("USER_009")) + .andExpect(jsonPath("$.message").value("탈퇴한 계정입니다.")); + } + + @Test + @DisplayName("정지된 계정 → 403 Forbidden") + void getMyBookmarks_suspendedUser() throws Exception { + // given + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + String accessToken = generateAccessToken(user); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + accessToken)) + .andDo(print()) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("USER_008")) + .andExpect(jsonPath("$.message").value("정지된 계정입니다. 관리자에게 문의하세요.")); + } + + @Test + @DisplayName("AccessToken 없음 → 401 Unauthorized") + void getMyBookmarks_noAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/bookmarks")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_001")) + .andExpect(jsonPath("$.message").value("인증이 필요합니다.")); + } + + @Test + @DisplayName("잘못된 AccessToken → 401 Unauthorized") + void getMyBookmarks_invalidAccessToken() throws Exception { + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer invalidToken")) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_002")) + .andExpect(jsonPath("$.message").value("유효하지 않은 액세스 토큰입니다.")); + } + + @Test + @DisplayName("만료된 AccessToken → 401 Unauthorized") + void getMyBookmarks_expiredAccessToken() throws Exception { + // given + User user = User.createUser("expired", "expired@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + String expiredToken = testJwtTokenProvider.createExpiredAccessToken(user.getId(), user.getUsername(), user.getRole().name()); + + // when & then + mvc.perform(get("/api/users/me/bookmarks") + .header("Authorization", "Bearer " + expiredToken)) + .andDo(print()) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("AUTH_004")) + .andExpect(jsonPath("$.message").value("만료된 액세스 토큰입니다.")); + } } diff --git a/src/test/java/com/back/domain/user/service/UserServiceTest.java b/src/test/java/com/back/domain/user/service/UserServiceTest.java index 9a8dcfda..ab64a894 100644 --- a/src/test/java/com/back/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/back/domain/user/service/UserServiceTest.java @@ -6,6 +6,8 @@ import com.back.domain.board.common.dto.PageResponse; import com.back.domain.board.post.dto.PostListResponse; import com.back.domain.board.post.entity.Post; +import com.back.domain.board.post.entity.PostBookmark; +import com.back.domain.board.post.repository.PostBookmarkRepository; import com.back.domain.board.post.repository.PostRepository; import com.back.domain.user.dto.ChangePasswordRequest; import com.back.domain.user.dto.UpdateUserProfileRequest; @@ -48,6 +50,9 @@ class UserServiceTest { @Autowired private PostRepository postRepository; + @Autowired + private PostBookmarkRepository postBookmarkRepository; + @Autowired private CommentRepository commentRepository; @@ -528,4 +533,69 @@ void getMyComments_suspendedUser() { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); } + + // ====================== 내 북마크 게시글 목록 조회 테스트 ====================== + + @Test + @DisplayName("내 북마크 게시글 목록 조회 성공") + void getMyBookmarks_success() { + // given + User user = User.createUser("bookmarkUser", "bookmark@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserProfile(new UserProfile(user, "닉네임", null, null, null, 0)); + user.setUserStatus(UserStatus.ACTIVE); + userRepository.save(user); + + Post post1 = new Post(user, "JPA 영속성 전이 완벽 정리", "내용1", null); + Post post2 = new Post(user, "테스트 코드 작성 가이드", "내용2", null); + postRepository.saveAll(List.of(post1, post2)); + + PostBookmark bookmark1 = new PostBookmark(post1, user); + PostBookmark bookmark2 = new PostBookmark(post2, user); + postBookmarkRepository.saveAll(List.of(bookmark1, bookmark2)); + + Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt")); + + // when + PageResponse response = userService.getMyBookmarks(user.getId(), pageable); + + // then + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).getTitle()).isEqualTo("테스트 코드 작성 가이드"); // 최신순 + assertThat(response.items().get(1).getTitle()).isEqualTo("JPA 영속성 전이 완벽 정리"); + } + + @Test + @DisplayName("존재하지 않는 사용자 → USER_NOT_FOUND 예외 발생") + void getMyBookmarks_userNotFound() { + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(999L, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("탈퇴된 사용자 → USER_DELETED 예외 발생") + void getMyBookmarks_deletedUser() { + User user = User.createUser("deleted", "deleted@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.DELETED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_DELETED.getMessage()); + } + + @Test + @DisplayName("정지된 사용자 → USER_SUSPENDED 예외 발생") + void getMyBookmarks_suspendedUser() { + User user = User.createUser("suspended", "suspended@example.com", passwordEncoder.encode("P@ssw0rd!")); + user.setUserStatus(UserStatus.SUSPENDED); + userRepository.save(user); + + Pageable pageable = PageRequest.of(0, 10); + assertThatThrownBy(() -> userService.getMyBookmarks(user.getId(), pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.USER_SUSPENDED.getMessage()); + } } From 801c6b95257e2c2bcb75e61999afdcf4fee8c26e Mon Sep 17 00:00:00 2001 From: joyewon0705 <77885098+joyewon0705@users.noreply.github.com> Date: Sun, 12 Oct 2025 17:22:45 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Docs:=20Swagger=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserControllerDocs.java | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java index 28c5d927..8536d780 100644 --- a/src/main/java/com/back/domain/user/controller/UserControllerDocs.java +++ b/src/main/java/com/back/domain/user/controller/UserControllerDocs.java @@ -926,4 +926,176 @@ ResponseEntity>> getMyComments( @AuthenticationPrincipal CustomUserDetails user, @ParameterObject Pageable pageable ); + + @Operation( + summary = "내 북마크 게시글 목록 조회", + description = """ + 로그인한 사용자가 북마크한 게시글 목록을 조회합니다. + - 기본 정렬: createdAt,desc + - 페이지 및 정렬 조건은 Query Parameter로 조정 가능합니다. + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "내 북마크 게시글 목록 조회 성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": true, + "code": "SUCCESS_200", + "message": "내 북마크 게시글 목록이 조회되었습니다.", + "data": { + "items": [ + { + "postId": 22, + "author": { "id": 3, "nickname": "홍길동", "profileImageUrl": null }, + "title": "JPA 영속성 전이 완벽 정리", + "thumbnailUrl": "https://cdn.example.com/thumbnails/jpa.png", + "categories": [ + { "id": 2, "name": "백엔드", "type": "SUBJECT" } + ], + "likeCount": 12, + "bookmarkCount": 7, + "commentCount": 3, + "createdAt": "2025-09-28T11:20:00", + "updatedAt": "2025-09-28T12:00:00" + }, + { + "postId": 10, + "author": { "id": 7, "nickname": "이자바", "profileImageUrl": null }, + "title": "테스트 코드 작성 가이드", + "thumbnailUrl": null, + "categories": [], + "likeCount": 2, + "bookmarkCount": 1, + "commentCount": 0, + "createdAt": "2025-09-25T09:10:00", + "updatedAt": "2025-09-25T09:10:00" + } + ], + "page": 0, + "size": 10, + "totalElements": 2, + "totalPages": 1, + "last": true + } + } + """) + ) + ), + @ApiResponse( + responseCode = "404", + description = "존재하지 않는 사용자", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_001", + "message": "존재하지 않는 사용자입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "410", + description = "탈퇴한 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_009", + "message": "탈퇴한 계정입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "403", + description = "정지된 계정", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "USER_008", + "message": "정지된 계정입니다. 관리자에게 문의하세요.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패 (토큰 없음/잘못됨/만료)", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "토큰 없음", value = """ + { + "success": false, + "code": "AUTH_001", + "message": "인증이 필요합니다.", + "data": null + } + """), + @ExampleObject(name = "잘못된 토큰", value = """ + { + "success": false, + "code": "AUTH_002", + "message": "유효하지 않은 액세스 토큰입니다.", + "data": null + } + """), + @ExampleObject(name = "만료된 토큰", value = """ + { + "success": false, + "code": "AUTH_004", + "message": "만료된 액세스 토큰입니다.", + "data": null + } + """) + } + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청(파라미터 오류)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_400", + "message": "잘못된 요청입니다.", + "data": null + } + """) + ) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "success": false, + "code": "COMMON_500", + "message": "서버 오류가 발생했습니다.", + "data": null + } + """) + ) + ) + }) + ResponseEntity>> getMyBookmarks( + @AuthenticationPrincipal CustomUserDetails user, + @ParameterObject Pageable pageable + ); }