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
10 changes: 10 additions & 0 deletions src/main/java/com/dokdok/book/dto/response/BookReviewResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ public static BookReviewResponse from(BookReview review) {
);
}

public static BookReviewResponse of(
Long reviewId,
Long bookId,
Long userId,
BigDecimal rating,
List<KeywordInfo> keywords
) {
return new BookReviewResponse(reviewId, bookId, userId, rating, keywords);
}

@Schema(description = "리뷰 키워드 정보")
public record KeywordInfo(
@Schema(description = "키워드 ID", example = "3")
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/dokdok/meeting/repository/MeetingRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ List<Meeting> findByMeetingEndDateBeforeAndMeetingStatus(
MeetingStatus meetingStatus
);

// Scheduler용: 시작 시간이 지났고 임시저장 사전의견이 있는 CONFIRMED 상태의 Meeting 조회
@Query("""
SELECT DISTINCT m
FROM Meeting m
JOIN FETCH m.book b
JOIN Topic t ON t.meeting = m
JOIN TopicAnswer ta ON ta.topic = t
WHERE m.meetingStartDate <= :now
AND m.meetingStatus = :meetingStatus
AND ta.isSubmitted = false
""")
List<Meeting> findStartedMeetingsWithDraftPreOpinions(
@Param("now") LocalDateTime now,
@Param("meetingStatus") MeetingStatus meetingStatus
);

Optional<Meeting> findTopByGatheringIdAndBookIdAndMeetingStatusOrderByMeetingStartDateDescIdDesc(
Long gatheringId,
Long bookId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.dokdok.meeting.entity.Meeting;
import com.dokdok.meeting.entity.MeetingStatus;
import com.dokdok.meeting.repository.MeetingRepository;
import com.dokdok.topic.service.PreOpinionAutoShareService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
Expand All @@ -23,6 +24,7 @@
public class MeetingStatusScheduler {

private final MeetingRepository meetingRepository;
private final PreOpinionAutoShareService preOpinionAutoShareService;

/**
* 종료 시간이 지난 모임의 상태를 자동으로 DONE으로 변경
Expand All @@ -31,6 +33,8 @@ public class MeetingStatusScheduler {
@Scheduled(cron = "0 */10 * * * *")
@Transactional
public void updateExpiredMeetings() {
autoShareStartedMeetings();

long startTime = System.currentTimeMillis();

LocalDateTime now = LocalDateTime.now();
Expand Down Expand Up @@ -66,4 +70,35 @@ public void updateExpiredMeetings() {
duration > 0 ? (count * 1000.0 / duration) : count);
log.info("[Scheduler] ========================================");
}

private void autoShareStartedMeetings() {
LocalDateTime now = LocalDateTime.now();
List<Meeting> startedMeetings = meetingRepository
.findStartedMeetingsWithDraftPreOpinions(now, MeetingStatus.CONFIRMED);

if (startedMeetings.isEmpty()) {
log.info("[Scheduler] No started meetings for pre-opinion auto share.");
return;
}

int submittedAnswerCount = 0;
int submittedUserCount = 0;
int appliedReviewCount = 0;

for (Meeting meeting : startedMeetings) {
try {
PreOpinionAutoShareService.AutoShareResult result =
preOpinionAutoShareService.autoShareDrafts(meeting);
submittedAnswerCount += result.submittedAnswerCount();
submittedUserCount += result.submittedUserCount();
appliedReviewCount += result.appliedReviewCount();
} catch (Exception e) {
log.error("[Scheduler] Failed to auto-share pre-opinions for meeting {}: {}",
meeting.getId(), e.getMessage());
}
}

log.info("[Scheduler] Auto-shared pre-opinions for {} meetings: answers={}, users={}, reviews={}",
startedMeetings.size(), submittedAnswerCount, submittedUserCount, appliedReviewCount);
}
}
13 changes: 9 additions & 4 deletions src/main/java/com/dokdok/topic/api/TopicAnswerApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ public interface TopicAnswerApi {
@Operation(
summary = "토픽 답변 일괄 저장 (developer: 양재웅)",
description = """
토픽 답변과 책 평가를 일괄 저장합니다.
토픽 답변과 사전의견 전용 책 평가를 임시 저장합니다.
- 권한: 모임 구성원
- 제약: 제출 완료된 답변은 수정 불가
- 책 평가는 사전의견 작성 화면에만 저장되며, 내 책장 리뷰에는 반영되지 않습니다.
""",
parameters = {
@Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true),
Expand Down Expand Up @@ -88,6 +89,7 @@ ResponseEntity<ApiResponse<PreOpinionSaveResponse>> createAnswer(
description = """
현재 로그인 사용자의 사전 의견 작성 화면 정보를 조회합니다.
- 권한: 모임 구성원
- review는 내 책장 리뷰가 아니라 사전의견 전용 책 평가입니다.
""",
parameters = {
@Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true),
Expand Down Expand Up @@ -129,9 +131,10 @@ ResponseEntity<ApiResponse<TopicAnswerDetailResponse>> findMyAnswer(
@Operation(
summary = "토픽 답변 일괄 저장 (developer: 양재웅)",
description = """
현재 로그인 사용자의 토픽 답변과 책 평가를 일괄 저장합니다.
현재 로그인 사용자의 토픽 답변과 사전의견 전용 책 평가를 임시 저장합니다.
- 권한: 모임 구성원
- 제약: 제출 완료된 답변은 수정 불가
- 책 평가는 사전의견 작성 화면에만 저장되며, 내 책장 리뷰에는 반영되지 않습니다.
""",
parameters = {
@Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true),
Expand Down Expand Up @@ -182,11 +185,13 @@ ResponseEntity<ApiResponse<PreOpinionSaveResponse>> updateMyAnswer(
);

@Operation(
summary = "토픽 답변 일괄 제출 (developer: 양재웅)",
summary = "토픽 답변 일괄 제출/공유 (developer: 양재웅)",
description = """
현재 로그인 사용자의 토픽 답변과 책 평가를 일괄 제출합니다.
현재 로그인 사용자의 토픽 답변을 제출하고 책 평가를 공유합니다.
- 권한: 모임 구성원
- 제약: 제출 완료된 답변은 재제출 불가
- 책 평가는 사전의견 전용 저장소에 저장된 뒤, 실제 내 책장 리뷰에도 반영됩니다.
- 응답의 reviewId는 사전의견 전용 책 평가 ID입니다.
""",
parameters = {
@Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
public record TopicAnswerBulkSaveRequest(
@NotNull
@Valid
@Schema(description = "책 평가 정보")
@Schema(description = "사전의견 전용 책 평가 정보. 저장 시 내 책장 리뷰에는 반영되지 않습니다.")
BookReviewRequest review,
@NotEmpty
@Valid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
public record TopicAnswerBulkSubmitRequest(
@NotNull
@Valid
@Schema(description = "책 평가 정보")
@Schema(description = "공유할 책 평가 정보. 제출 시 사전의견 전용 저장소와 내 책장 리뷰에 반영됩니다.")
BookReviewRequest review,
@NotEmpty
@Schema(description = "제출할 토픽 ID 목록", example = "[1,2,3]")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

@Schema(description = "사전 의견 일괄 저장 응답")
public record PreOpinionSaveResponse(
@Schema(description = "책 평가 응답")
@Schema(description = "사전의견 전용 책 평가 응답")
BookReviewResponse review,
@Schema(description = "토픽 답변 저장 결과")
List<TopicAnswerResponse> answers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

@Schema(description = "사전 의견 일괄 제출 응답")
public record PreOpinionSubmitResponse(
@Schema(description = "책 평가 응답")
@Schema(description = "사전의견 전용 책 평가 응답. 책장 리뷰에도 같은 내용이 반영됩니다.")
BookReviewResponse review,
@Schema(description = "토픽 답변 제출 결과")
List<TopicAnswerSubmitResponse> answers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
public record TopicAnswerDetailResponse(
@Schema(description = "책 정보")
BookInfo book,
@Schema(description = "책 평가 정보")
@Schema(description = "사전의견 전용 책 평가 정보")
BookReviewResponse review,
@Schema(description = "사전 의견 정보")
PreOpinion preOpinion
Expand Down
133 changes: 133 additions & 0 deletions src/main/java/com/dokdok/topic/entity/PreOpinionBookReview.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.dokdok.topic.entity;

import com.dokdok.book.entity.Book;
import com.dokdok.global.BaseTimeEntity;
import com.dokdok.keyword.entity.Keyword;
import com.dokdok.meeting.entity.Meeting;
import com.dokdok.user.entity.User;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

@Entity
@Table(name = "pre_opinion_book_review")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@SuperBuilder
@SQLDelete(sql = "UPDATE pre_opinion_book_review SET deleted_at = CURRENT_TIMESTAMP WHERE pre_opinion_book_review_id = ?")
@SQLRestriction("deleted_at IS NULL")
public class PreOpinionBookReview extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pre_opinion_book_review_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "meeting_id", nullable = false)
private Meeting meeting;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private Book book;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(name = "rating", precision = 2, scale = 1)
private BigDecimal rating;

@OneToMany(mappedBy = "preOpinionBookReview", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<PreOpinionBookReviewKeyword> keywords = new ArrayList<>();

public static PreOpinionBookReview create(
Meeting meeting,
Book book,
User user,
BigDecimal rating,
List<Keyword> keywords
) {
PreOpinionBookReview review = PreOpinionBookReview.builder()
.meeting(meeting)
.book(book)
.user(user)
.rating(rating)
.build();
review.replaceKeywords(keywords);
return review;
}

public void updateReview(BigDecimal rating, List<Keyword> keywords) {
boolean ratingChanged = !Objects.equals(this.rating, rating);
boolean keywordsChanged = updateKeywords(keywords);

if (ratingChanged) {
this.rating = rating;
}

if (ratingChanged || keywordsChanged) {
this.touch();
}
}

public void deleteReview() {
this.markDeletedNow();
}

private void replaceKeywords(List<Keyword> keywords) {
this.keywords = new ArrayList<>();
for (Keyword keyword : keywords) {
this.keywords.add(PreOpinionBookReviewKeyword.create(this, keyword));
}
}

private boolean updateKeywords(List<Keyword> newKeywords) {
Set<Long> newKeywordIds = newKeywords.stream()
.map(Keyword::getId)
.collect(Collectors.toSet());

Set<Long> existingKeywordIds = this.keywords.stream()
.map(reviewKeyword -> reviewKeyword.getKeyword().getId())
.collect(Collectors.toSet());

boolean hasChanges = !newKeywordIds.equals(existingKeywordIds);

if (hasChanges) {
this.keywords.removeIf(reviewKeyword -> !newKeywordIds.contains(reviewKeyword.getKeyword().getId()));

for (Keyword keyword : newKeywords) {
if (!existingKeywordIds.contains(keyword.getId())) {
this.keywords.add(PreOpinionBookReviewKeyword.create(this, keyword));
}
}
}

return hasChanges;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.dokdok.topic.entity;

import com.dokdok.keyword.entity.Keyword;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Entity
@Table(name = "pre_opinion_book_review_keyword")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class PreOpinionBookReviewKeyword {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pre_opinion_book_review_keyword_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pre_opinion_book_review_id", nullable = false)
private PreOpinionBookReview preOpinionBookReview;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "keyword_id", nullable = false)
private Keyword keyword;

@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

public static PreOpinionBookReviewKeyword create(PreOpinionBookReview preOpinionBookReview, Keyword keyword) {
return PreOpinionBookReviewKeyword.builder()
.preOpinionBookReview(preOpinionBookReview)
.keyword(keyword)
.build();
}

@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}
Loading
Loading