Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -332,4 +330,128 @@ ResponseEntity<ApiResponse<Void>> 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,
"title": "가짜욕망, 유사 욕망",
"confirmOrder": 1,
"answerId": 101,
"content": "어쩌구 저쩌구..."
},
{
"topicId": 2,
"title": "진정한 자아 찾기",
"confirmOrder": 2,
"answerId": 102,
"content": "저쩌구 어쩌구..."
}
]
},
{
"userId": 2,
"nickname": "독서왕",
"profileImageUrl": "https://example.com/profile2.jpg",
"topics": [
{
"topicId": 1,
"title": "가짜욕망, 유사 욕망",
"confirmOrder": 1,
"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<ApiResponse<CursorResponse<MemberAnswerResponse, CollectedAnswersCursor>>> 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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,4 +69,16 @@ public ResponseEntity<ApiResponse<Void>> deleteMeetingRetrospective(

return ApiResponse.deleted("공동 회고 코멘트 삭제 완료");
}

@Override
@GetMapping("/collected-answers")
public ResponseEntity<ApiResponse<CursorResponse<MemberAnswerResponse, CollectedAnswersCursor>>> getCollectedAnswers(
@PathVariable Long meetingId,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) Long cursorUserId
) {
CursorResponse<MemberAnswerResponse, CollectedAnswersCursor> response =
meetingRetrospectiveService.getCollectedAnswers(meetingId, pageSize, cursorUserId);
return ApiResponse.success(response, "수집된 사전 의견 조회 성공");
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberAnswerResponse> items,

@Schema(description = "페이지 크기", example = "10")
int pageSize,

@Schema(description = "다음 페이지 존재 여부", example = "true")
boolean hasNext,

@Schema(description = "다음 커서")
CollectedAnswersCursor nextCursor,

@Schema(description = "전체 답변 수 (첫 페이지 요청 시에만 제공)", example = "8")
Integer totalCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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<TopicAnswerItem> topics
) {
@Schema(description = "주제별 사전 답변")
public record TopicAnswerItem(
@Schema(description = "주제 ID",example = "1")
Long topicId,

@Schema(description = "주제 제목", example = "가짜욕망 유사욕망")
String title,

@Schema(description = "확정 순서", example = "1")
Integer confirmOrder,

@Schema(description = "답변 ID",example = "101")
Long answerId,

@Schema(description = "답변 내용",example = "어쩌구 저쩌구 ...")
String content
){
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -42,6 +43,7 @@ public class MeetingRetrospectiveService {
private final MeetingValidator meetingValidator;
private final TopicValidator topicValidator;
private final StorageService storageService;
private final TopicAnswerRepository topicAnswerRepository;

/**
* 공동 회고 조회 ( 토픽 정보 + 요약 + 키포인트, 코멘트 제외 )
Expand Down Expand Up @@ -186,4 +188,98 @@ private CommentCursor buildNextCursor(List<MeetingRetrospective> comments, boole
MeetingRetrospective lastComment = comments.get(comments.size() - 1);
return CommentCursor.from(lastComment);
}

/**
* 수집된 사전 의견 조회 (멤버별 그룹화, 커서 기반 무한 스크롤)
*/
public CursorResponse<MemberAnswerResponse, CollectedAnswersCursor> 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<MemberAnswerResponse, CollectedAnswersCursor> fetchCollectedAnswers(
Long meetingId,
int pageSize,
Long cursorUserId) {
Pageable pageable = PageRequest.of(0, pageSize + 1);
boolean isFirstPage = cursorUserId == null;

// 1. userId 목록 조회 (pageSize + 1개)
List<Long> userIds = isFirstPage
? topicAnswerRepository.findDistinctUserIdsByMeetingIdFirstPage(meetingId, pageable)
: topicAnswerRepository.findDistinctUserIdsByMeetingIdAfterCursor(meetingId, cursorUserId, pageable);

// 2. hasNext 판단
boolean hasNext = userIds.size() > pageSize;
List<Long> pageUserIds = hasNext
? userIds.subList(0, pageSize)
: userIds;

// 3. 해당 userId들의 답변 조회
List<TopicAnswer> answers = pageUserIds.isEmpty()
? List.of()
: topicAnswerRepository.findSubmittedAnswersByMeetingIdAndUserIds(meetingId, pageUserIds);

// 4. 멤버별로 그룹화하여 Response 생성
List<MemberAnswerResponse> 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<MemberAnswerResponse> buildMemberAnswerResponses(
List<TopicAnswer> answers,
List<Long> orderedUserIds
) {
// userId -> 답변 목록 그룹화
Map<Long, List<TopicAnswer>> answersByUserId = answers.stream()
.collect(Collectors.groupingBy(ta -> ta.getUser().getId()));

// userId 순서 유지하며 Response 생성
return orderedUserIds.stream()
.filter(answersByUserId::containsKey)
.map(uid -> {
List<TopicAnswer> userAnswers = answersByUserId.get(uid);
User user = userAnswers.get(0).getUser();
String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl());

List<MemberAnswerResponse.TopicAnswerItem> topics = userAnswers.stream()
.map(ta -> new MemberAnswerResponse.TopicAnswerItem(
ta.getTopic().getId(),
ta.getTopic().getTitle(),
ta.getTopic().getConfirmOrder(),
ta.getId(),
ta.getContent()
))
.toList();

return new MemberAnswerResponse(
user.getId(),
user.getNickname(),
presignedUrl,
topics
);
})
.toList();
}

private CollectedAnswersCursor buildCollectedAnswersCursor(List<Long> userIds, boolean hasNext) {
if (!hasNext || userIds.isEmpty()) {
return null;
}
Long lastUserId = userIds.get(userIds.size() - 1);
return CollectedAnswersCursor.from(lastUserId);
}
}
2 changes: 1 addition & 1 deletion src/main/java/com/dokdok/topic/api/PreOpinionApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading