From 11e620bba2c7e5d3e8a3125712393e26d7113af1 Mon Sep 17 00:00:00 2001 From: juhyun Date: Sun, 8 Feb 2026 21:54:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor=20:=20swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/dokdok/topic/api/PreOpinionApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/dokdok/topic/api/PreOpinionApi.java b/src/main/java/com/dokdok/topic/api/PreOpinionApi.java index 99d0cc72..4803349e 100644 --- a/src/main/java/com/dokdok/topic/api/PreOpinionApi.java +++ b/src/main/java/com/dokdok/topic/api/PreOpinionApi.java @@ -45,7 +45,7 @@ public interface PreOpinionApi { content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PreOpinionResponse.class), examples = @ExampleObject(value = """ - {"code":"SUCCESS","message":"약속의 사전 의견 목록 조회를 성공했습니다.","data":{"topics":[{"topicId":1,"title":"책의 주요 메시지","description":"이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?","topicType":"DISCUSSION","topicTypeLabel":"토론형","confirmOrder":1}],"members":[{"memberInfo":{"memberId":1,"nickname":"독서왕","profileImage":"https://example.com/profile.jpg","role":"GATHERING_LEADER"},"bookReview":{"rating":4.5,"keywordInfo":[{"id":1,"name":"성장","type":"BOOK"},{"id":2,"name":"여운이 남는","type":"IMPRESSION"}]},"topicOpinions":[{"topicId":1,"content":"저는 이 책의 핵심 메시지가 자기 성찰이라고 생각합니다."}],"isSubmitted":true}]}} + {"code":"SUCCESS","message":"약속의 사전 의견 목록 조회를 성공했습니다.","data":{"topics":[{"topicId":1,"title":"책의 주요 메시지","description":"이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?","topicType":"DISCUSSION","topicTypeLabel":"토론형","confirmOrder":1}],"members":[{"memberInfo":{"userId":1,"nickname":"독서왕","profileImage":"https://example.com/profile.jpg","role":"GATHERING_LEADER"},"bookReview":{"rating":4.5,"keywordInfo":[{"id":1,"name":"성장","type":"BOOK"},{"id":2,"name":"여운이 남는","type":"IMPRESSION"}]},"topicOpinions":[{"topicId":1,"content":"저는 이 책의 핵심 메시지가 자기 성찰이라고 생각합니다."}],"isSubmitted":true}]}} """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( From 2d95339233c2f43a5974a58af55a185054ece2d2 Mon Sep 17 00:00:00 2001 From: juhyun Date: Sun, 8 Feb 2026 22:57:35 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat=20:=20=EC=88=98=EC=A7=91=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=9D=98=EA=B2=AC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/MeetingRetrospectiveApi.java | 125 +++++++++++++++++- .../MeetingRetrospectiveController.java | 14 ++ .../dto/response/CollectedAnswersCursor.java | 13 ++ .../CollectedAnswersCursorResponse.java | 24 ++++ .../dto/response/MemberAnswerResponse.java | 36 +++++ .../service/MeetingRetrospectiveService.java | 99 +++++++++++++- .../repository/TopicAnswerRepository.java | 69 ++++++++++ 7 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursor.java create mode 100644 src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursorResponse.java create mode 100644 src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java diff --git a/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java b/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java index b13cf965..3f75b42b 100644 --- a/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java +++ b/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java @@ -3,9 +3,7 @@ import com.dokdok.global.response.ApiResponse; import com.dokdok.global.response.CursorResponse; import com.dokdok.retrospective.dto.request.MeetingRetrospectiveRequest; -import com.dokdok.retrospective.dto.response.CommentCursor; -import com.dokdok.retrospective.dto.response.MeetingRetrospectiveResponse; -import com.dokdok.retrospective.dto.response.TopicCommentCursorResponse; +import com.dokdok.retrospective.dto.response.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -332,4 +330,125 @@ ResponseEntity> deleteMeetingRetrospective( @PathVariable Long meetingId, @PathVariable Long meetingRetrospectiveId ); + + @Operation( + summary = "수집된 사전 의견 조회 (developer: 오주현)", + description = """ + 약속 회고 생성 화면에서 멤버별로 그룹화된 사전 의견을 조회합니다. + - 권한: 약속장만 조회 가능 + - 커서 기반 무한스크롤을 지원합니다. + - 첫 페이지: cursorUserId 없이 호출 + - 다음 페이지: 응답의 nextCursor.userId 값을 cursorUserId로 전달 + - 정렬: userId ASC + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "수집된 사전 의견 조회 성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CollectedAnswersCursorResponse.class), + examples = @ExampleObject(value = """ + { + "code": "SUCCESS", + "message": "수집된 사전 의견 조회 성공", + "data": { + "items": [ + { + "userId": 1, + "nickname": "곰곰", + "profileImageUrl": "https://example.com/profile.jpg", + "topics": [ + { + "topicId": 1, + "topicTitle": "가짜욕망, 유사 욕망", + "answerId": 101, + "content": "어쩌구 저쩌구..." + }, + { + "topicId": 2, + "topicTitle": "진정한 자아 찾기", + "answerId": 102, + "content": "저쩌구 어쩌구..." + } + ] + }, + { + "userId": 2, + "nickname": "독서왕", + "profileImageUrl": "https://example.com/profile2.jpg", + "topics": [ + { + "topicId": 1, + "topicTitle": "가짜욕망, 유사 욕망", + "answerId": 103, + "content": "내 생각은..." + } + ] + } + ], + "pageSize": 10, + "hasNext": true, + "nextCursor": { + "userId": 2 + }, + "totalCount": 8 + } + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패 - 로그인이 필요합니다.", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "G102", "message": "인증이 필요합니다.", "data": null} + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음 - 약속장만 조회할 수 있습니다.", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "M003", "message": "약속장만 가능한 작업입니다.", "data": null} + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "약속을 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "M001", "message": "약속을 찾을 수 없습니다.", "data": null} + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "서버 오류", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + {"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null} + """) + ) + ) + }) + @GetMapping("/collected-answers") + ResponseEntity>> getCollectedAnswers( + @Parameter(description = "약속 ID", required = true, example = "1") + @PathVariable Long meetingId, + + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(defaultValue = "10") int pageSize, + + @Parameter(description = "커서 - 마지막 멤버의 userId", example = "2") + @RequestParam(required = false) Long cursorUserId + ); } diff --git a/src/main/java/com/dokdok/retrospective/controller/MeetingRetrospectiveController.java b/src/main/java/com/dokdok/retrospective/controller/MeetingRetrospectiveController.java index dfc6818d..c1a441a4 100644 --- a/src/main/java/com/dokdok/retrospective/controller/MeetingRetrospectiveController.java +++ b/src/main/java/com/dokdok/retrospective/controller/MeetingRetrospectiveController.java @@ -4,8 +4,10 @@ import com.dokdok.global.response.CursorResponse; import com.dokdok.retrospective.api.MeetingRetrospectiveApi; import com.dokdok.retrospective.dto.request.MeetingRetrospectiveRequest; +import com.dokdok.retrospective.dto.response.CollectedAnswersCursor; import com.dokdok.retrospective.dto.response.CommentCursor; import com.dokdok.retrospective.dto.response.MeetingRetrospectiveResponse; +import com.dokdok.retrospective.dto.response.MemberAnswerResponse; import com.dokdok.retrospective.service.MeetingRetrospectiveService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -67,4 +69,16 @@ public ResponseEntity> deleteMeetingRetrospective( return ApiResponse.deleted("공동 회고 코멘트 삭제 완료"); } + + @Override + @GetMapping("/collected-answers") + public ResponseEntity>> getCollectedAnswers( + @PathVariable Long meetingId, + @RequestParam(defaultValue = "10") int pageSize, + @RequestParam(required = false) Long cursorUserId + ) { + CursorResponse response = + meetingRetrospectiveService.getCollectedAnswers(meetingId, pageSize, cursorUserId); + return ApiResponse.success(response, "수집된 사전 의견 조회 성공"); + } } diff --git a/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursor.java b/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursor.java new file mode 100644 index 00000000..cc32f184 --- /dev/null +++ b/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursor.java @@ -0,0 +1,13 @@ +package com.dokdok.retrospective.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "수집된 사전 의견 커서") +public record CollectedAnswersCursor( + @Schema(description = "마지막 멤버의 사용자 ID", example = "3") + Long userId +) { + public static CollectedAnswersCursor from(Long userId) { + return new CollectedAnswersCursor(userId); + } +} diff --git a/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursorResponse.java b/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursorResponse.java new file mode 100644 index 00000000..cab59ee1 --- /dev/null +++ b/src/main/java/com/dokdok/retrospective/dto/response/CollectedAnswersCursorResponse.java @@ -0,0 +1,24 @@ +package com.dokdok.retrospective.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "수집된 사전 의견 목록 커서 응답(문서용)") +public record CollectedAnswersCursorResponse( + @Schema(description = "멤버별 사전 의견 목록") + List items, + + @Schema(description = "페이지 크기", example = "10") + int pageSize, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext, + + @Schema(description = "다음 커서") + CollectedAnswersCursor nextCursor, + + @Schema(description = "전체 답변 수 (첫 페이지 요청 시에만 제공)", example = "8") + Integer totalCount +) { +} diff --git a/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java b/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java new file mode 100644 index 00000000..b822fa62 --- /dev/null +++ b/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java @@ -0,0 +1,36 @@ +package com.dokdok.retrospective.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "멤버별 수집된 주제 답변") +public record MemberAnswerResponse( + @Schema(description = "사용자 ID",example = "1") + Long userId, + + @Schema(description = "사용자 닉네임", example = "독서왕") + String nickname, + + @Schema(description = "프로필 이미지 URL") + String profileImageUrl, + + @Schema(description = "주제별 사전 답변목록") + List topics +) { + @Schema(description = "주제별 사전 답변") + public record TopicAnswerItem( + @Schema(description = "주제 ID",example = "1") + Long topicId, + + @Schema(description = "주제 제목", example = "가짜욕망 유사욕망") + String topicTitle, + + @Schema(description = "답변 ID",example = "101") + Long answerId, + + @Schema(description = "답변 내용",example = "어쩌구 저쩌구 ...") + String content + ){ + } +} diff --git a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java index cc53f4d9..a4a8e2d7 100644 --- a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java +++ b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java @@ -5,8 +5,7 @@ import com.dokdok.meeting.entity.Meeting; import com.dokdok.meeting.service.MeetingValidator; import com.dokdok.retrospective.dto.request.MeetingRetrospectiveRequest; -import com.dokdok.retrospective.dto.response.CommentCursor; -import com.dokdok.retrospective.dto.response.MeetingRetrospectiveResponse; +import com.dokdok.retrospective.dto.response.*; import com.dokdok.retrospective.entity.MeetingRetrospective; import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; import com.dokdok.retrospective.exception.RetrospectiveErrorCode; @@ -15,7 +14,9 @@ import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; import com.dokdok.storage.service.StorageService; import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; import com.dokdok.topic.entity.TopicStatus; +import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.topic.service.TopicValidator; import com.dokdok.user.entity.User; @@ -42,6 +43,7 @@ public class MeetingRetrospectiveService { private final MeetingValidator meetingValidator; private final TopicValidator topicValidator; private final StorageService storageService; + private final TopicAnswerRepository topicAnswerRepository; /** * 공동 회고 조회 ( 토픽 정보 + 요약 + 키포인트, 코멘트 제외 ) @@ -186,4 +188,97 @@ private CommentCursor buildNextCursor(List comments, boole MeetingRetrospective lastComment = comments.get(comments.size() - 1); return CommentCursor.from(lastComment); } + + /** + * 수집된 사전 의견 조회 (멤버별 그룹화, 커서 기반 무한 스크롤) + */ + public CursorResponse getCollectedAnswers( + Long meetingId, + int pageSize, + Long cursorUserId) { + Long userId = SecurityUtil.getCurrentUserId(); + Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId); + + meetingValidator.validateMeetingLeader(meeting, userId); + + return fetchCollectedAnswers(meetingId, pageSize, cursorUserId); + } + + private CursorResponse fetchCollectedAnswers( + Long meetingId, + int pageSize, + Long cursorUserId) { + Pageable pageable = PageRequest.of(0, pageSize + 1); + boolean isFirstPage = cursorUserId == null; + + // 1. userId 목록 조회 (pageSize + 1개) + List userIds = isFirstPage + ? topicAnswerRepository.findDistinctUserIdsByMeetingIdFirstPage(meetingId, pageable) + : topicAnswerRepository.findDistinctUserIdsByMeetingIdAfterCursor(meetingId, cursorUserId, pageable); + + // 2. hasNext 판단 + boolean hasNext = userIds.size() > pageSize; + List pageUserIds = hasNext + ? userIds.subList(0, pageSize) + : userIds; + + // 3. 해당 userId들의 답변 조회 + List answers = pageUserIds.isEmpty() + ? List.of() + : topicAnswerRepository.findSubmittedAnswersByMeetingIdAndUserIds(meetingId, pageUserIds); + + // 4. 멤버별로 그룹화하여 Response 생성 + List items = buildMemberAnswerResponses(answers, pageUserIds); + + // 5. 커서 및 totalCount 생성 + CollectedAnswersCursor nextCursor = buildCollectedAnswersCursor(pageUserIds, hasNext); + Integer totalCount = isFirstPage + ? topicAnswerRepository.countSubmittedAnswersByMeetingId(meetingId) + : null; + + return CursorResponse.of(items, pageSize, hasNext, nextCursor, totalCount); + } + + private List buildMemberAnswerResponses( + List answers, + List orderedUserIds + ) { + // userId -> 답변 목록 그룹화 + Map> answersByUserId = answers.stream() + .collect(Collectors.groupingBy(ta -> ta.getUser().getId())); + + // userId 순서 유지하며 Response 생성 + return orderedUserIds.stream() + .filter(answersByUserId::containsKey) + .map(uid -> { + List userAnswers = answersByUserId.get(uid); + User user = userAnswers.get(0).getUser(); + String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl()); + + List topics = userAnswers.stream() + .map(ta -> new MemberAnswerResponse.TopicAnswerItem( + ta.getTopic().getId(), + ta.getTopic().getTitle(), + ta.getId(), + ta.getContent() + )) + .toList(); + + return new MemberAnswerResponse( + user.getId(), + user.getNickname(), + presignedUrl, + topics + ); + }) + .toList(); + } + + private CollectedAnswersCursor buildCollectedAnswersCursor(List userIds, boolean hasNext) { + if (!hasNext || userIds.isEmpty()) { + return null; + } + Long lastUserId = userIds.get(userIds.size() - 1); + return CollectedAnswersCursor.from(lastUserId); + } } diff --git a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java index 2c096cc1..ffccf1d0 100644 --- a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java +++ b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java @@ -1,6 +1,7 @@ package com.dokdok.topic.repository; import com.dokdok.topic.entity.TopicAnswer; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -104,4 +105,72 @@ List findByTopicAnswers( @Param("meetingId") Long meetingId, @Param("userId") Long userId ); + + // === 수집된 사전 의견 조회용 쿼리 === + + /** + * 첫 페이지: 제출된 답변이 있는 userId 목록 조회 (distinct, 오름차순) + */ + @Query(""" + SELECT DISTINCT u.id + FROM TopicAnswer ta + JOIN ta.user u + JOIN ta.topic t + WHERE t.meeting.id = :meetingId + AND ta.isSubmitted = true + ORDER BY u.id ASC + """) + List findDistinctUserIdsByMeetingIdFirstPage( + @Param("meetingId") Long meetingId, + Pageable pageable + ); + + /** + * 다음 페이지: 커서 이후 userId 목록 조회 + */ + @Query(""" + SELECT DISTINCT u.id + FROM TopicAnswer ta + JOIN ta.user u + JOIN ta.topic t + WHERE t.meeting.id = :meetingId + AND ta.isSubmitted = true + AND u.id > :cursorUserId + ORDER BY u.id ASC + """) + List findDistinctUserIdsByMeetingIdAfterCursor( + @Param("meetingId") Long meetingId, + @Param("cursorUserId") Long cursorUserId, + Pageable pageable + ); + + /** + * userId 목록으로 제출된 답변 조회 (user, topic fetch) + */ + @Query(""" + SELECT ta + FROM TopicAnswer ta + JOIN FETCH ta.user u + JOIN FETCH ta.topic t + WHERE t.meeting.id = :meetingId + AND ta.isSubmitted = true + AND u.id IN :userIds + ORDER BY u.id ASC, t.id ASC + """) + List findSubmittedAnswersByMeetingIdAndUserIds( + @Param("meetingId") Long meetingId, + @Param("userIds") List userIds + ); + + /** + * 제출된 답변 총 개수 + */ + @Query(""" + SELECT COUNT(ta) + FROM TopicAnswer ta + JOIN ta.topic t + WHERE t.meeting.id = :meetingId + AND ta.isSubmitted = true + """) + int countSubmittedAnswersByMeetingId(@Param("meetingId") Long meetingId); } From ef8946e52290df530f4c77e61457db22c4835263 Mon Sep 17 00:00:00 2001 From: juhyun Date: Sun, 8 Feb 2026 22:57:43 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat=20:=20=EC=88=98=EC=A7=91=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=20=EC=9D=98=EA=B2=AC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?test=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MeetingRetrospectiveServiceTest.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java b/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java index 85951aff..e4750381 100644 --- a/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java +++ b/src/test/java/com/dokdok/retrospective/service/MeetingRetrospectiveServiceTest.java @@ -3,6 +3,7 @@ import com.dokdok.gathering.entity.Gathering; import com.dokdok.gathering.exception.GatheringErrorCode; import com.dokdok.gathering.exception.GatheringException; +import com.dokdok.global.response.CursorResponse; import com.dokdok.global.util.SecurityUtil; import com.dokdok.meeting.entity.Meeting; import com.dokdok.meeting.exception.MeetingErrorCode; @@ -10,7 +11,9 @@ import com.dokdok.meeting.service.MeetingValidator; import com.dokdok.oauth2.CustomOAuth2User; import com.dokdok.retrospective.dto.request.MeetingRetrospectiveRequest; +import com.dokdok.retrospective.dto.response.CollectedAnswersCursor; import com.dokdok.retrospective.dto.response.MeetingRetrospectiveResponse; +import com.dokdok.retrospective.dto.response.MemberAnswerResponse; import com.dokdok.retrospective.entity.MeetingRetrospective; import com.dokdok.retrospective.entity.TopicRetrospectiveSummary; import com.dokdok.retrospective.exception.RetrospectiveErrorCode; @@ -19,7 +22,9 @@ import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository; import com.dokdok.storage.service.StorageService; import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; import com.dokdok.topic.entity.TopicStatus; +import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.topic.service.TopicValidator; import com.dokdok.user.entity.User; @@ -30,6 +35,7 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; @@ -57,6 +63,9 @@ class MeetingRetrospectiveServiceTest { @Mock private StorageService storageService; + @Mock + private TopicAnswerRepository topicAnswerRepository; + @InjectMocks private MeetingRetrospectiveService meetingRetrospectiveService; @@ -367,4 +376,44 @@ void deleteMeetingRetrospective_throwsWhenForbidden() { verify(retrospectiveRepository, never()).delete(any()); } } + + @Test + @DisplayName("수집된 사전 의견 조회 - 첫 페이지") + void getCollectedAnswers_firstPage_success() { + Long meetingId = 1L; + Long userId = 1L; + int pageSize = 1; + + User leader = User.builder().id(userId).nickname("리더").profileImageUrl("leader.jpg").build(); + Meeting meeting = Meeting.builder().id(meetingId).meetingLeader(leader).build(); + Topic topic = Topic.builder().id(10L).title("주제").build(); + TopicAnswer answer = TopicAnswer.builder() + .id(100L) + .topic(topic) + .user(leader) + .content("답변") + .isSubmitted(true) + .build(); + + try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + + when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting); + when(topicAnswerRepository.findDistinctUserIdsByMeetingIdFirstPage(eq(meetingId), any(Pageable.class))) + .thenReturn(List.of(userId, 2L)); + when(topicAnswerRepository.findSubmittedAnswersByMeetingIdAndUserIds(meetingId, List.of(userId))) + .thenReturn(List.of(answer)); + when(topicAnswerRepository.countSubmittedAnswersByMeetingId(meetingId)).thenReturn(2); + when(storageService.getPresignedProfileImage("leader.jpg")).thenReturn("leader.jpg"); + + CursorResponse response = + meetingRetrospectiveService.getCollectedAnswers(meetingId, pageSize, null); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).userId()).isEqualTo(userId); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor().userId()).isEqualTo(userId); + assertThat(response.totalCount()).isEqualTo(2); + } + } } From 803ba9c9fb631923720c93905c40e75509bf3469 Mon Sep 17 00:00:00 2001 From: juhyun Date: Mon, 9 Feb 2026 00:01:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor=20:=20DTO=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retrospective/dto/response/MemberAnswerResponse.java | 5 ++++- .../com/dokdok/topic/repository/TopicAnswerRepository.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java b/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java index b822fa62..6b15da13 100644 --- a/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java +++ b/src/main/java/com/dokdok/retrospective/dto/response/MemberAnswerResponse.java @@ -24,7 +24,10 @@ public record TopicAnswerItem( Long topicId, @Schema(description = "주제 제목", example = "가짜욕망 유사욕망") - String topicTitle, + String title, + + @Schema(description = "확정 순서", example = "1") + Integer confirmOrder, @Schema(description = "답변 ID",example = "101") Long answerId, diff --git a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java index ffccf1d0..a2f16e47 100644 --- a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java +++ b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java @@ -155,7 +155,7 @@ List findDistinctUserIdsByMeetingIdAfterCursor( WHERE t.meeting.id = :meetingId AND ta.isSubmitted = true AND u.id IN :userIds - ORDER BY u.id ASC, t.id ASC + ORDER BY u.id ASC, t.confirmOrder ASC NULLS LAST, t.id ASC """) List findSubmittedAnswersByMeetingIdAndUserIds( @Param("meetingId") Long meetingId, From 610bf73d3e5467b1f714578716c3eeeceb936101 Mon Sep 17 00:00:00 2001 From: juhyun Date: Mon, 9 Feb 2026 00:08:37 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor=20:=20DTO=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retrospective/service/MeetingRetrospectiveService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java index a4a8e2d7..7979b0f3 100644 --- a/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java +++ b/src/main/java/com/dokdok/retrospective/service/MeetingRetrospectiveService.java @@ -259,6 +259,7 @@ private List buildMemberAnswerResponses( .map(ta -> new MemberAnswerResponse.TopicAnswerItem( ta.getTopic().getId(), ta.getTopic().getTitle(), + ta.getTopic().getConfirmOrder(), ta.getId(), ta.getContent() )) From 639573e857251c3a48f2c0c63e6d68978fb5132f Mon Sep 17 00:00:00 2001 From: juhyun Date: Mon, 9 Feb 2026 00:18:16 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor=20:=20swagger=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retrospective/api/MeetingRetrospectiveApi.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java b/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java index 3f75b42b..5932d308 100644 --- a/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java +++ b/src/main/java/com/dokdok/retrospective/api/MeetingRetrospectiveApi.java @@ -362,13 +362,15 @@ ResponseEntity> deleteMeetingRetrospective( "topics": [ { "topicId": 1, - "topicTitle": "가짜욕망, 유사 욕망", + "title": "가짜욕망, 유사 욕망", + "confirmOrder": 1, "answerId": 101, "content": "어쩌구 저쩌구..." }, { "topicId": 2, - "topicTitle": "진정한 자아 찾기", + "title": "진정한 자아 찾기", + "confirmOrder": 2, "answerId": 102, "content": "저쩌구 어쩌구..." } @@ -381,7 +383,8 @@ ResponseEntity> deleteMeetingRetrospective( "topics": [ { "topicId": 1, - "topicTitle": "가짜욕망, 유사 욕망", + "title": "가짜욕망, 유사 욕망", + "confirmOrder": 1, "answerId": 103, "content": "내 생각은..." }