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
27 changes: 12 additions & 15 deletions src/main/java/com/dokdok/book/repository/BookReviewRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,16 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.math.BigDecimal;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Map;
import java.util.Optional;

public interface BookReviewRepository extends JpaRepository<BookReview, Long> {
Optional<BookReview> findByBookIdAndUserId(Long bookId, Long userId);

boolean existsByBookIdAndUserId(Long bookId, Long userId);

@Query("""
SELECT br
FROM BookReview br
WHERE br.user.id = :userId
AND br.createdAt = (
SELECT MAX(br2.createdAt)
FROM BookReview br2
WHERE br2.user.id = br.user.id
)
""")
List<BookReview> findByUserId(Long userId);

@Query("""
SELECT br
FROM BookReview br
Expand All @@ -36,8 +24,17 @@ SELECT MAX(br2.createdAt)
FROM BookReview br2
WHERE br2.user.id = br.user.id
)
AND EXISTS (SELECT ta
FROM TopicAnswer ta
JOIN ta.topic t
WHERE ta.user.id = br.user.id
AND t.meeting.id = :meetingId
AND ta.isSubmitted = true)
""")
List<BookReview> findByUserIdIn(List<Long> userIds);
List<BookReview> findByUserIdIn(
@Param("userIds") List<Long> userIds,
@Param("meetingId") Long meetingId
);

@Query("""
SELECT new com.dokdok.gathering.dto.response.BookRatingAverage(
Expand Down
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 @@ -44,7 +44,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":"DISCUSSION","confirmOrder":1}],"members":[{"memberInfo":{"memberId":1,"nickname":"독서왕","profileImage":"https://example.com/profile.jpg"},"bookReview":{"rating":4.5,"bookKeywords":["성장","관계"],"impressionKeywords":["여운이 남는","즐거운"]},"topicOpinions":[{"topicId":1,"content":"저는 이 책의 핵심 메시지가 자기 성찰이라고 생각합니다."}]}]}}
{"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}]}}
"""))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand Down
49 changes: 38 additions & 11 deletions src/main/java/com/dokdok/topic/dto/response/PreOpinionResponse.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.dokdok.topic.dto.response;

import com.dokdok.book.entity.KeywordType;
import com.dokdok.topic.entity.Topic;
import com.dokdok.topic.entity.TopicAnswer;
import com.dokdok.topic.entity.TopicType;
Expand Down Expand Up @@ -59,7 +60,10 @@ public record MemberPreOpinion(
BookReviewInfo bookReview,

@Schema(description = "주제별 의견 목록")
List<TopicOpinion> topicOpinions
List<TopicOpinion> topicOpinions,

@Schema(description = "답변 제출 여부")
Boolean isSubmitted
) {
}

Expand All @@ -72,10 +76,18 @@ public record MemberInfo(
String nickname,

@Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg")
String profileImage
String profileImage,

@Schema(description = "역할", example = "MEMBER")
String role
) {
public static MemberInfo of(Long memberId, String nickname, String profileImage) {
return new MemberInfo(memberId, nickname, profileImage);
public static MemberInfo of(
Long memberId,
String nickname,
String profileImage,
String role
) {
return new MemberInfo(memberId, nickname, profileImage, role);
}
}

Expand All @@ -85,17 +97,32 @@ public record BookReviewInfo(
BigDecimal rating,

@Schema(description = "책 키워드 목록", example = "[\"성장\", \"관계\"]")
List<String> bookKeywords,

@Schema(description = "인상 키워드 목록", example = "[\"여운이 남는\", \"즐거운\"]")
List<String> impressionKeywords
List<KeywordInfo> keywordInfo
) {
public static BookReviewInfo of(
BigDecimal rating,
List<String> bookKeywords,
List<String> impressionKeywords
List<KeywordInfo> keywordInfo
) {
return new BookReviewInfo(rating, keywordInfo);
}
}

@Schema(description = "리뷰 키워드 정보")
public record KeywordInfo(
@Schema(description = "키워드 ID", example = "3")
Long id,
@Schema(description = "키워드 이름", example = "판타지")
String name,
@Schema(description = "키워드 타입", example = "BOOK")
KeywordType type
) {

public static KeywordInfo of(
Long id,
String name,
KeywordType type
) {
return new BookReviewInfo(rating, bookKeywords, impressionKeywords);
return new KeywordInfo(id, name, type);
}
}

Expand Down
140 changes: 105 additions & 35 deletions src/main/java/com/dokdok/topic/service/PreOpinionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,30 @@

import com.dokdok.book.entity.BookReview;
import com.dokdok.book.entity.BookReviewKeyword;
import com.dokdok.book.entity.KeywordType;
import com.dokdok.book.repository.BookReviewKeywordRepository;
import com.dokdok.book.repository.BookReviewRepository;
import com.dokdok.gathering.entity.GatheringMember;
import com.dokdok.gathering.entity.GatheringRole;
import com.dokdok.gathering.repository.GatheringMemberRepository;
import com.dokdok.gathering.service.GatheringValidator;
import com.dokdok.global.util.SecurityUtil;
import com.dokdok.meeting.entity.MeetingMember;
import com.dokdok.meeting.entity.MeetingMemberRole;
import com.dokdok.meeting.repository.MeetingMemberRepository;
import com.dokdok.meeting.service.MeetingValidator;
import com.dokdok.storage.service.StorageService;
import com.dokdok.topic.dto.response.PreOpinionResponse;
import com.dokdok.topic.dto.response.PreOpinionResponse.BookReviewInfo;
import com.dokdok.topic.entity.TopicAnswer;
import com.dokdok.topic.repository.TopicAnswerRepository;
import com.dokdok.topic.repository.TopicRepository;
import com.dokdok.user.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -28,6 +34,7 @@
@RequiredArgsConstructor
public class PreOpinionService {

private final GatheringMemberRepository gatheringMemberRepository;
private final GatheringValidator gatheringValidator;
private final MeetingValidator meetingValidator;
private final TopicValidator topicValidator;
Expand All @@ -46,7 +53,7 @@ public PreOpinionResponse findPreOpinions(Long gatheringId, Long meetingId) {
List<PreOpinionResponse.TopicInfo> topicInfos = buildTopicInfos(meetingId);
List<MeetingMember> meetingMembers = meetingMemberRepository.findAllByMeetingId(meetingId);

List<PreOpinionResponse.MemberPreOpinion> preOpinionData = buildPreOpinionData(meetingId, meetingMembers);
List<PreOpinionResponse.MemberPreOpinion> preOpinionData = buildPreOpinionData(gatheringId, meetingId, meetingMembers);

return new PreOpinionResponse(topicInfos, preOpinionData);
}
Expand All @@ -64,30 +71,50 @@ private List<PreOpinionResponse.TopicInfo> buildTopicInfos(Long meetingId) {
.toList();
}

private List<PreOpinionResponse.MemberPreOpinion> buildPreOpinionData(Long meetingId, List<MeetingMember> meetingMembers) {
private List<PreOpinionResponse.MemberPreOpinion> buildPreOpinionData(Long gatheringId, Long meetingId, List<MeetingMember> meetingMembers) {
PreOpinionMaps maps = fetchPreOpinionMaps(gatheringId, meetingId, meetingMembers);
return assembleMembers(meetingMembers, maps);
}

private record PreOpinionMaps(
Map<Long, GatheringRole> gatheringRoleByUserId,
Map<Long, BookReview> bookReviewByUserId,
Map<Long, List<BookReviewKeyword>> keywordsByReviewId,
Map<Long, List<PreOpinionResponse.TopicOpinion>> topicAnswersByUserId,
Map<Long, LocalDateTime> earliestAnswerByUserId
) {}

private PreOpinionMaps fetchPreOpinionMaps(Long gatheringId, Long meetingId, List<MeetingMember> meetingMembers) {
List<Long> userIds = meetingMembers.stream()
.map(mm -> mm.getUser().getId())
.toList();

// 모든 멤버의 책 평가 일괄 조회
Map<Long, BookReview> bookReviewByUserId = bookReviewRepository.findByUserIdIn(userIds).stream()
Map<Long, GatheringRole> gatheringRoleByUserId = gatheringMemberRepository
.findAllMembersByGatheringId(gatheringId).stream()
.collect(Collectors.toMap(
gm -> gm.getUser().getId(),
GatheringMember::getRole,
(existing, replacement) -> existing
));

Map<Long, BookReview> bookReviewByUserId = bookReviewRepository.findByUserIdIn(userIds, meetingId).stream()
.collect(Collectors.toMap(
br -> br.getUser().getId(),
br -> br,
(existing, replacement) -> existing
));

// 책 평가별 키워드 일괄 조회
List<Long> bookReviewIds = bookReviewByUserId.values().stream()
.map(BookReview::getId)
.toList();
Map<Long, List<BookReviewKeyword>> keywordsByReviewId = bookReviewKeywordRepository
.findByBookReviewIds(bookReviewIds).stream()
.collect(Collectors.groupingBy(k -> k.getBookReview().getId()));

// 주제 답변 일괄 조회
List<TopicAnswer> allTopicAnswers = topicAnswerRepository.findByMeetingId(meetingId);

Map<Long, List<PreOpinionResponse.TopicOpinion>> topicAnswersByUserId =
topicAnswerRepository.findByMeetingId(meetingId).stream()
allTopicAnswers.stream()
.collect(Collectors.groupingBy(
ta -> ta.getUser().getId(),
Collectors.mapping(
Expand All @@ -96,47 +123,90 @@ private List<PreOpinionResponse.MemberPreOpinion> buildPreOpinionData(Long meeti
)
));

Map<Long, LocalDateTime> earliestAnswerByUserId = allTopicAnswers.stream()
.collect(Collectors.toMap(
ta -> ta.getUser().getId(),
TopicAnswer::getCreatedAt,
(a, b) -> a.isBefore(b) ? a : b
));

return new PreOpinionMaps(
gatheringRoleByUserId,
bookReviewByUserId,
keywordsByReviewId,
topicAnswersByUserId,
earliestAnswerByUserId
);
}

private List<PreOpinionResponse.MemberPreOpinion> assembleMembers(List<MeetingMember> meetingMembers, PreOpinionMaps maps) {
return meetingMembers.stream()
.map(mm -> {
User user = mm.getUser();
Long memberId = user.getId();
.sorted(Comparator.comparing(
(MeetingMember mm) -> maps.earliestAnswerByUserId().getOrDefault(
mm.getUser().getId(), LocalDateTime.MAX)
))
.map(mm -> toMemberPreOpinion(mm, maps))
.toList();
}

String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl());
PreOpinionResponse.MemberInfo memberInfo
= PreOpinionResponse.MemberInfo.of(memberId, user.getNickname(), presignedUrl);
private PreOpinionResponse.MemberPreOpinion toMemberPreOpinion(MeetingMember mm, PreOpinionMaps maps) {
User user = mm.getUser();
Long memberId = user.getId();

BookReview review = bookReviewByUserId.get(memberId);
BookReviewInfo bookReviewInfo = review != null
? toBookReviewInfo(review, keywordsByReviewId)
: null;
String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl());
String role = resolveRole(mm, maps.gatheringRoleByUserId());

List<PreOpinionResponse.TopicOpinion> topicAnswers = topicAnswersByUserId.getOrDefault(memberId, List.of());
PreOpinionResponse.MemberInfo memberInfo
= PreOpinionResponse.MemberInfo.of(memberId, user.getNickname(), presignedUrl, role);

return new PreOpinionResponse.MemberPreOpinion(memberInfo, bookReviewInfo, topicAnswers);
})
.toList();
BookReview review = maps.bookReviewByUserId().get(memberId);
BookReviewInfo bookReviewInfo = review != null
? toBookReviewInfo(review, maps.keywordsByReviewId())
: null;

List<PreOpinionResponse.TopicOpinion> topicAnswers = maps.topicAnswersByUserId().getOrDefault(memberId, List.of());

return new PreOpinionResponse.MemberPreOpinion(memberInfo, bookReviewInfo, topicAnswers, maps.topicAnswersByUserId().containsKey(memberId));
}

/**
* 모임장 / 약속장 구별을 위한 메서드
*/
private String resolveRole(MeetingMember mm, Map<Long, GatheringRole> gatheringRoleByUserId) {
Long userId = mm.getUser().getId();
GatheringRole gatheringRole = gatheringRoleByUserId.get(userId);

if (gatheringRole == GatheringRole.LEADER) {
return "GATHERING_LEADER";
}
if (mm.getMeetingRole() == MeetingMemberRole.LEADER) {
return "MEETING_LEADER";
}
return "MEMBER";
}

/**
* 멤버들의 책 리뷰 조회
* - 사전의견을 발행하지 않은 사용자는 책 평가도 반환하지 않음
*/
private BookReviewInfo toBookReviewInfo(
BookReview bookReview,
Map<Long, List<BookReviewKeyword>> keywordsByReviewId) {
Map<Long, List<BookReviewKeyword>> keywordsByReviewId
) {
List<BookReviewKeyword> reviewKeywords =
keywordsByReviewId.getOrDefault(bookReview.getId(), List.of());
Map<KeywordType, List<String>> keywordMap = toKeywordMap(reviewKeywords);

List<PreOpinionResponse.KeywordInfo> keywordInfos = reviewKeywords.stream()
.map(rk -> PreOpinionResponse.KeywordInfo.of(
rk.getKeyword().getId(),
rk.getKeyword().getKeywordName(),
rk.getKeyword().getKeywordType()
))
.toList();

return BookReviewInfo.of(
bookReview.getRating(),
keywordMap.getOrDefault(KeywordType.BOOK, List.of()),
keywordMap.getOrDefault(KeywordType.IMPRESSION, List.of())
keywordInfos
);
}

private Map<KeywordType, List<String>> toKeywordMap(List<BookReviewKeyword> keywords) {
return keywords.stream()
.collect(Collectors.groupingBy(
k -> k.getKeyword().getKeywordType(),
Collectors.mapping(k -> k.getKeyword().getKeywordName(), Collectors.toList())
));
}
}
}
Loading