From 7f14156ca62cf81d4dcfa18790506907aa0494c5 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Tue, 10 Feb 2026 22:58:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EC=9D=BC=EA=B4=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5,=20=EC=A0=9C=EC=B6=9C=20api=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20/=20=ED=8F=89=EA=B0=80=EC=99=80=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EB=8B=B5=EB=B3=80=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dokdok/topic/api/TopicAnswerApi.java | 93 ++++---- .../controller/TopicAnswerController.java | 52 ++--- .../request/TopicAnswerBulkSaveRequest.java | 34 +++ .../request/TopicAnswerBulkSubmitRequest.java | 21 ++ .../topic/dto/request/TopicAnswerRequest.java | 11 - .../dto/response/PreOpinionSaveResponse.java | 18 ++ .../response/PreOpinionSubmitResponse.java | 18 ++ .../response/TopicAnswerDetailResponse.java | 88 ++++++-- .../topic/service/TopicAnswerService.java | 204 +++++++++++++----- .../topic/service/TopicAnswerServiceTest.java | 181 ++++++++++------ 10 files changed, 513 insertions(+), 207 deletions(-) create mode 100644 src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java create mode 100644 src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSubmitRequest.java delete mode 100644 src/main/java/com/dokdok/topic/dto/request/TopicAnswerRequest.java create mode 100644 src/main/java/com/dokdok/topic/dto/response/PreOpinionSaveResponse.java create mode 100644 src/main/java/com/dokdok/topic/dto/response/PreOpinionSubmitResponse.java diff --git a/src/main/java/com/dokdok/topic/api/TopicAnswerApi.java b/src/main/java/com/dokdok/topic/api/TopicAnswerApi.java index 51193ac7..309f4df8 100644 --- a/src/main/java/com/dokdok/topic/api/TopicAnswerApi.java +++ b/src/main/java/com/dokdok/topic/api/TopicAnswerApi.java @@ -1,10 +1,11 @@ package com.dokdok.topic.api; import com.dokdok.global.response.ApiResponse; -import com.dokdok.topic.dto.request.TopicAnswerRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSaveRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSubmitRequest; +import com.dokdok.topic.dto.response.PreOpinionSaveResponse; +import com.dokdok.topic.dto.response.PreOpinionSubmitResponse; import com.dokdok.topic.dto.response.TopicAnswerDetailResponse; -import com.dokdok.topic.dto.response.TopicAnswerResponse; -import com.dokdok.topic.dto.response.TopicAnswerSubmitResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -16,7 +17,6 @@ import jakarta.validation.Valid; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,28 +25,30 @@ import org.springframework.web.bind.annotation.RequestMapping; @Tag(name = "토픽 답변", description = "토픽 답변 관련 API") -@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}/answers") +@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/answers") public interface TopicAnswerApi { @Operation( - summary = "토픽 답변 저장 (developer: 양재웅)", + summary = "토픽 답변 일괄 저장 (developer: 양재웅)", description = """ - 토픽 답변을 저장합니다. + 토픽 답변과 책 평가를 일괄 저장합니다. - 권한: 모임 구성원 - - 제약: 동일 토픽에 대해 1회만 저장 가능 + - 제약: 제출 완료된 답변은 수정 불가 """, parameters = { @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topicId", description = "토픽 식별자", in = ParameterIn.PATH, required = true) + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "201", - description = "토픽 답변 저장 성공", + description = "토픽 답변 일괄 저장 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicAnswerResponse.class)) + schema = @Schema(implementation = PreOpinionSaveResponse.class), + examples = @ExampleObject(value = """ + {"code":"CREATED","message":"사전 의견이 저장되었습니다.","data":{"review":{"reviewId":1,"bookId":10,"userId":1,"rating":4.5,"keywords":[]},"answers":[{"topicId":1,"isSubmitted":false,"updatedAt":"2026-01-19T20:01:37.105545"}]}} + """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -75,31 +77,32 @@ public interface TopicAnswerApi { """))) }) @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity> createAnswer( + ResponseEntity> createAnswer( @PathVariable("gatheringId") Long gatheringId, @PathVariable("meetingId") Long meetingId, - @PathVariable("topicId") Long topicId, - @Valid @RequestBody TopicAnswerRequest request + @Valid @RequestBody TopicAnswerBulkSaveRequest request ); @Operation( - summary = "내 토픽 답변 조회 (developer: 양재웅)", + summary = "사전 의견 작성 화면 조회 (developer: 양재웅)", description = """ - 현재 로그인 사용자의 토픽 답변을 조회합니다. + 현재 로그인 사용자의 사전 의견 작성 화면 정보를 조회합니다. - 권한: 모임 구성원 """, parameters = { - @Parameter(name = "gathering_id", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meeting_id", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topic_id", description = "토픽 식별자", in = ParameterIn.PATH, required = true) + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "내 토픽 답변 조회 성공", + description = "사전 의견 작성 화면 조회 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicAnswerDetailResponse.class)) + schema = @Schema(implementation = TopicAnswerDetailResponse.class), + examples = @ExampleObject(value = """ + {"code":"SUCCESS","message":"사전 의견 작성 화면 조회를 성공했습니다.","data":{"book":{"bookId":10,"title":"아주 작은 습관의 힘","author":"제임스 클리어"},"review":{"reviewId":1,"bookId":10,"userId":1,"rating":4.5,"keywords":[]},"preOpinion":{"updatedAt":"2026-02-06T09:12:30","topics":[{"topicId":1,"topicTitle":"책의 주요 메시지","topicDescription":"이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?","topicType":"DISCUSSION","topicTypeLabel":"토론형","confirmOrder":1,"content":"이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다."},{"topicId":2,"topicTitle":"인상 깊은 구절","topicDescription":"가장 인상 깊었던 문장을 공유해주세요.","topicType":"EMOTION","topicTypeLabel":"감정 공유형","confirmOrder":2,"content":null}]}}} + """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "모임 멤버가 아님", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -119,30 +122,31 @@ ResponseEntity> createAnswer( }) @GetMapping(value = "/me", produces = MediaType.APPLICATION_JSON_VALUE) ResponseEntity> findMyAnswer( - @PathVariable("gathering_id") Long gatheringId, - @PathVariable("meeting_id") Long meetingId, - @PathVariable("topic_id") Long topicId + @PathVariable("gatheringId") Long gatheringId, + @PathVariable("meetingId") Long meetingId ); @Operation( - summary = "내 토픽 답변 수정 (developer: 양재웅)", + summary = "토픽 답변 일괄 저장 (developer: 양재웅)", description = """ - 현재 로그인 사용자의 토픽 답변을 수정합니다. + 현재 로그인 사용자의 토픽 답변과 책 평가를 일괄 저장합니다. - 권한: 모임 구성원 - 제약: 제출 완료된 답변은 수정 불가 """, parameters = { - @Parameter(name = "gathering_id", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meeting_id", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topic_id", description = "토픽 식별자", in = ParameterIn.PATH, required = true) + @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "내 토픽 답변 수정 성공", + description = "토픽 답변 일괄 저장 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicAnswerResponse.class)) + schema = @Schema(implementation = PreOpinionSaveResponse.class), + examples = @ExampleObject(value = """ + {"code":"UPDATED","message":"사전 의견이 저장되었습니다.","data":{"review":{"reviewId":1,"bookId":10,"userId":1,"rating":4.5,"keywords":[]},"answers":[{"topicId":1,"isSubmitted":false,"updatedAt":"2026-01-19T20:01:37.105545"}]}} + """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -171,32 +175,33 @@ ResponseEntity> findMyAnswer( """))) }) @PatchMapping(value = "/me", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity> updateMyAnswer( + ResponseEntity> updateMyAnswer( @PathVariable("gatheringId") Long gatheringId, @PathVariable("meetingId") Long meetingId, - @PathVariable("topicId") Long topicId, - @Valid @RequestBody TopicAnswerRequest request + @Valid @RequestBody TopicAnswerBulkSaveRequest request ); @Operation( - summary = "내 토픽 답변 제출 (developer: 양재웅)", + summary = "토픽 답변 일괄 제출 (developer: 양재웅)", description = """ - 현재 로그인 사용자의 토픽 답변을 제출합니다. + 현재 로그인 사용자의 토픽 답변과 책 평가를 일괄 제출합니다. - 권한: 모임 구성원 - 제약: 제출 완료된 답변은 재제출 불가 """, parameters = { @Parameter(name = "gatheringId", description = "모임 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true), - @Parameter(name = "topicId", description = "토픽 식별자", in = ParameterIn.PATH, required = true) + @Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true) } ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "내 토픽 답변 제출 성공", + description = "토픽 답변 일괄 제출 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = TopicAnswerSubmitResponse.class)) + schema = @Schema(implementation = PreOpinionSubmitResponse.class), + examples = @ExampleObject(value = """ + {"code":"SUCCESS","message":"사전 의견이 제출되었습니다.","data":{"review":{"reviewId":1,"bookId":10,"userId":1,"rating":4.5,"keywords":[]},"answers":[{"topicId":1,"isSubmitted":true,"submittedAt":"2026-01-19T20:01:37.105545"}]}} + """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "모임 멤버가 아님", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -219,11 +224,11 @@ ResponseEntity> updateMyAnswer( {"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null} """))) }) - @PatchMapping(value = "/submit", produces = MediaType.APPLICATION_JSON_VALUE) - ResponseEntity> submitMyAnswer( + @PatchMapping(value = "/submit", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity> submitMyAnswer( @PathVariable("gatheringId") Long gatheringId, @PathVariable("meetingId") Long meetingId, - @PathVariable("topicId") Long topicId + @Valid @RequestBody TopicAnswerBulkSubmitRequest request ); } diff --git a/src/main/java/com/dokdok/topic/controller/TopicAnswerController.java b/src/main/java/com/dokdok/topic/controller/TopicAnswerController.java index 6f5e482f..90fa470a 100644 --- a/src/main/java/com/dokdok/topic/controller/TopicAnswerController.java +++ b/src/main/java/com/dokdok/topic/controller/TopicAnswerController.java @@ -2,73 +2,73 @@ import com.dokdok.global.response.ApiResponse; import com.dokdok.topic.api.TopicAnswerApi; -import com.dokdok.topic.dto.request.TopicAnswerRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSaveRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSubmitRequest; +import com.dokdok.topic.dto.response.PreOpinionSaveResponse; +import com.dokdok.topic.dto.response.PreOpinionSubmitResponse; import com.dokdok.topic.dto.response.TopicAnswerDetailResponse; -import com.dokdok.topic.dto.response.TopicAnswerResponse; -import com.dokdok.topic.dto.response.TopicAnswerSubmitResponse; import com.dokdok.topic.service.TopicAnswerService; import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; + @RestController @RequiredArgsConstructor -@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}/answers") +@RequestMapping("/api/gatherings/{gatheringId}/meetings/{meetingId}/answers") public class TopicAnswerController implements TopicAnswerApi { private final TopicAnswerService topicAnswerService; @Override - public ResponseEntity> createAnswer( + public ResponseEntity> createAnswer( Long gatheringId, Long meetingId, - Long topicId, - TopicAnswerRequest request + @Valid @RequestBody TopicAnswerBulkSaveRequest request ) { - TopicAnswerResponse response = topicAnswerService.createAnswer( - gatheringId, meetingId, topicId, request + PreOpinionSaveResponse response = topicAnswerService.createAnswer( + gatheringId, meetingId, request ); - return ApiResponse.created(response, "답변이 저장되었습니다."); + return ApiResponse.created(response, "사전 의견이 저장되었습니다."); } @Override public ResponseEntity> findMyAnswer( Long gatheringId, - Long meetingId, - Long topicId + Long meetingId ) { TopicAnswerDetailResponse response = topicAnswerService.getMyAnswer( - gatheringId, meetingId, topicId + gatheringId, meetingId ); - return ApiResponse.success(response, "조회 성공"); + return ApiResponse.success(response, "사전 의견 작성 화면 조회를 성공했습니다."); } @Override - public ResponseEntity> updateMyAnswer( + public ResponseEntity> updateMyAnswer( Long gatheringId, Long meetingId, - Long topicId, - TopicAnswerRequest request + @Valid @RequestBody TopicAnswerBulkSaveRequest request ) { - TopicAnswerResponse response = topicAnswerService.updateMyAnswer( - gatheringId, meetingId, topicId, request + PreOpinionSaveResponse response = topicAnswerService.updateMyAnswer( + gatheringId, meetingId, request ); - return ApiResponse.updated(response, "답변이 수정되었습니다."); + return ApiResponse.updated(response, "사전 의견이 저장되었습니다."); } @Override - public ResponseEntity> submitMyAnswer( + public ResponseEntity> submitMyAnswer( Long gatheringId, Long meetingId, - Long topicId + @Valid @RequestBody TopicAnswerBulkSubmitRequest request ) { - TopicAnswerSubmitResponse response = topicAnswerService.submitMyAnswer( - gatheringId, meetingId, topicId + PreOpinionSubmitResponse response = topicAnswerService.submitMyAnswer( + gatheringId, meetingId, request ); - return ApiResponse.success(response, "답변이 제출되었습니다."); + return ApiResponse.success(response, "사전 의견이 제출되었습니다."); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java new file mode 100644 index 00000000..8405d4d0 --- /dev/null +++ b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java @@ -0,0 +1,34 @@ +package com.dokdok.topic.dto.request; + +import com.dokdok.book.dto.request.BookReviewRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +@Schema(description = "사전 의견 일괄 저장 요청") +public record TopicAnswerBulkSaveRequest( + @NotNull + @Valid + @Schema(description = "책 평가 정보") + BookReviewRequest review, + @NotEmpty + @Valid + @Schema(description = "답변 목록") + List answers +) { + public record AnswerItem( + @NotNull + @Schema(description = "토픽 ID", example = "1") + Long topicId, + @NotBlank(message = "설명은 필수입니다") + @Size(max = 1000, message = "설명은 1000자 이내여야 합니다") + @Schema(description = "답변 내용", example = "이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다.") + String content + ) { + } +} diff --git a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSubmitRequest.java b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSubmitRequest.java new file mode 100644 index 00000000..00e0d977 --- /dev/null +++ b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSubmitRequest.java @@ -0,0 +1,21 @@ +package com.dokdok.topic.dto.request; + +import com.dokdok.book.dto.request.BookReviewRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +@Schema(description = "사전 의견 일괄 제출 요청") +public record TopicAnswerBulkSubmitRequest( + @NotNull + @Valid + @Schema(description = "책 평가 정보") + BookReviewRequest review, + @NotEmpty + @Schema(description = "제출할 토픽 ID 목록", example = "[1,2,3]") + List<@NotNull Long> topicIds +) { +} diff --git a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerRequest.java b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerRequest.java deleted file mode 100644 index 7c75e805..00000000 --- a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.dokdok.topic.dto.request; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; - -public record TopicAnswerRequest( - @NotBlank(message = "설명은 필수입니다") - @Size(max = 1000, message = "설명은 1000자 이내여야 합니다") - String content -) { -} diff --git a/src/main/java/com/dokdok/topic/dto/response/PreOpinionSaveResponse.java b/src/main/java/com/dokdok/topic/dto/response/PreOpinionSaveResponse.java new file mode 100644 index 00000000..1193509c --- /dev/null +++ b/src/main/java/com/dokdok/topic/dto/response/PreOpinionSaveResponse.java @@ -0,0 +1,18 @@ +package com.dokdok.topic.dto.response; + +import com.dokdok.book.dto.response.BookReviewResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "사전 의견 일괄 저장 응답") +public record PreOpinionSaveResponse( + @Schema(description = "책 평가 응답") + BookReviewResponse review, + @Schema(description = "토픽 답변 저장 결과") + List answers +) { + public static PreOpinionSaveResponse of(BookReviewResponse review, List answers) { + return new PreOpinionSaveResponse(review, answers); + } +} diff --git a/src/main/java/com/dokdok/topic/dto/response/PreOpinionSubmitResponse.java b/src/main/java/com/dokdok/topic/dto/response/PreOpinionSubmitResponse.java new file mode 100644 index 00000000..c2c37c03 --- /dev/null +++ b/src/main/java/com/dokdok/topic/dto/response/PreOpinionSubmitResponse.java @@ -0,0 +1,18 @@ +package com.dokdok.topic.dto.response; + +import com.dokdok.book.dto.response.BookReviewResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "사전 의견 일괄 제출 응답") +public record PreOpinionSubmitResponse( + @Schema(description = "책 평가 응답") + BookReviewResponse review, + @Schema(description = "토픽 답변 제출 결과") + List answers +) { + public static PreOpinionSubmitResponse of(BookReviewResponse review, List answers) { + return new PreOpinionSubmitResponse(review, answers); + } +} diff --git a/src/main/java/com/dokdok/topic/dto/response/TopicAnswerDetailResponse.java b/src/main/java/com/dokdok/topic/dto/response/TopicAnswerDetailResponse.java index 72f0328f..0caca93c 100644 --- a/src/main/java/com/dokdok/topic/dto/response/TopicAnswerDetailResponse.java +++ b/src/main/java/com/dokdok/topic/dto/response/TopicAnswerDetailResponse.java @@ -1,26 +1,82 @@ package com.dokdok.topic.dto.response; +import com.dokdok.book.dto.response.BookReviewResponse; +import com.dokdok.book.entity.Book; +import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicAnswer; +import com.dokdok.topic.entity.TopicType; import io.swagger.v3.oas.annotations.media.Schema; + import java.time.LocalDateTime; +import java.util.List; -@Schema(description = "내 토픽 답변 상세 응답") +@Schema(description = "사전 의견 작성 화면 응답") public record TopicAnswerDetailResponse( - @Schema(description = "토픽 ID", example = "12") - Long topicId, - @Schema(description = "답변 내용", example = "이 책을 읽고 ...") - String content, - @Schema(description = "제출 여부", example = "true") - boolean isSubmitted, - @Schema(description = "수정 일시", example = "2026-01-19T20:01:37.105545") - LocalDateTime updatedAt + @Schema(description = "책 정보") + BookInfo book, + @Schema(description = "책 평가 정보") + BookReviewResponse review, + @Schema(description = "사전 의견 정보") + PreOpinion preOpinion ) { - public static TopicAnswerDetailResponse from(TopicAnswer answer) { - return new TopicAnswerDetailResponse( - answer.getTopic().getId(), - answer.getContent(), - Boolean.TRUE.equals(answer.getIsSubmitted()), - answer.getUpdatedAt() - ); + public record BookInfo( + @Schema(description = "책 ID", example = "10") + Long bookId, + @Schema(description = "책 제목", example = "아주 작은 습관의 힘") + String title, + @Schema(description = "저자", example = "제임스 클리어") + String author + ) { + public static BookInfo from(Book book) { + if (book == null) { + return null; + } + return new BookInfo(book.getId(), book.getBookName(), book.getAuthor()); + } + } + + public record PreOpinion( + @Schema(description = "최근 저장 일시", example = "2026-02-06T09:12:30") + LocalDateTime updatedAt, + @Schema(description = "주제별 사전 의견 목록") + List topics + ) { + } + + public record PreOpinionTopic( + @Schema(description = "주제 ID", example = "1") + Long topicId, + @Schema(description = "주제 제목", example = "책의 주요 메시지") + String topicTitle, + @Schema(description = "주제 설명", example = "이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?") + String topicDescription, + @Schema(description = "주제 타입", example = "DISCUSSION") + TopicType topicType, + @Schema(description = "주제 타입 라벨", example = "토론형") + String topicTypeLabel, + @Schema(description = "확정 순서", example = "1") + Integer confirmOrder, + @Schema(description = "사전 의견 내용", example = "이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다.") + String content + ) { + public static PreOpinionTopic of(Topic topic, TopicAnswer answer) { + return new PreOpinionTopic( + topic.getId(), + topic.getTitle(), + topic.getDescription(), + topic.getTopicType(), + topic.getTopicType().getDisplayName(), + topic.getConfirmOrder(), + answer != null ? answer.getContent() : null + ); + } + } + + public static TopicAnswerDetailResponse of( + BookInfo book, + BookReviewResponse review, + PreOpinion preOpinion + ) { + return new TopicAnswerDetailResponse(book, review, preOpinion); } } diff --git a/src/main/java/com/dokdok/topic/service/TopicAnswerService.java b/src/main/java/com/dokdok/topic/service/TopicAnswerService.java index d977ba3f..1df8f173 100644 --- a/src/main/java/com/dokdok/topic/service/TopicAnswerService.java +++ b/src/main/java/com/dokdok/topic/service/TopicAnswerService.java @@ -1,9 +1,18 @@ package com.dokdok.topic.service; +import com.dokdok.book.dto.request.BookReviewRequest; +import com.dokdok.book.dto.response.BookReviewResponse; +import com.dokdok.book.exception.BookErrorCode; +import com.dokdok.book.exception.BookException; +import com.dokdok.book.repository.BookReviewRepository; +import com.dokdok.book.service.BookReviewService; import com.dokdok.gathering.service.GatheringValidator; import com.dokdok.global.util.SecurityUtil; import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.dto.request.TopicAnswerRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSaveRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSubmitRequest; +import com.dokdok.topic.dto.response.PreOpinionSaveResponse; +import com.dokdok.topic.dto.response.PreOpinionSubmitResponse; import com.dokdok.topic.dto.response.TopicAnswerDetailResponse; import com.dokdok.topic.dto.response.TopicAnswerResponse; import com.dokdok.topic.dto.response.TopicAnswerSubmitResponse; @@ -18,7 +27,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -26,100 +39,195 @@ public class TopicAnswerService { private final TopicAnswerRepository topicAnswerRepository; private final TopicRepository topicRepository; + private final BookReviewRepository bookReviewRepository; + private final BookReviewService bookReviewService; private final GatheringValidator gatheringValidator; private final MeetingValidator meetingValidator; - private final TopicValidator topicValidator; @Transactional - public TopicAnswerResponse createAnswer( + public PreOpinionSaveResponse createAnswer( Long gatheringId, Long meetingId, - Long topicId, - TopicAnswerRequest request + TopicAnswerBulkSaveRequest request ) { - Long userId = SecurityUtil.getCurrentUserId(); - - gatheringValidator.validateMembership(gatheringId, userId); - meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - topicValidator.validateTopicInMeeting(topicId, meetingId); - - boolean exists = topicAnswerRepository.existsByTopicIdAndUserId(topicId, userId); - if (exists) { - throw new TopicException(TopicErrorCode.TOPIC_ANSWER_ALREADY_EXISTS); - } - - Topic topic = topicRepository.getReferenceById(topicId); - User user = SecurityUtil.getCurrentUserEntity(); - - TopicAnswer saved = topicAnswerRepository.save( - TopicAnswer.create(topic, user, request.content()) - ); - - return TopicAnswerResponse.from(saved); + return saveAnswersBulk(gatheringId, meetingId, request); } @Transactional(readOnly = true) public TopicAnswerDetailResponse getMyAnswer( Long gatheringId, - Long meetingId, - Long topicId + Long meetingId ) { Long userId = SecurityUtil.getCurrentUserId(); gatheringValidator.validateMembership(gatheringId, userId); meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - topicValidator.validateTopicInMeeting(topicId, meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); + + var meeting = meetingValidator.findMeetingOrThrow(meetingId); + + List topics = topicRepository.findTopicsInfoByMeetingId(meetingId); + Map answersByTopicId = topicAnswerRepository.findByMeetingIdUserId(meetingId, userId) + .stream() + .collect(Collectors.toMap( + answer -> answer.getTopic().getId(), + answer -> answer + )); + + LocalDateTime latestUpdatedAt = answersByTopicId.values().stream() + .map(TopicAnswer::getUpdatedAt) + .max(LocalDateTime::compareTo) + .orElse(null); + + List topicInfos = topics.stream() + .map(topic -> TopicAnswerDetailResponse.PreOpinionTopic.of( + topic, + answersByTopicId.get(topic.getId()) + )) + .toList(); + + TopicAnswerDetailResponse.BookInfo bookInfo = TopicAnswerDetailResponse.BookInfo.from(meeting.getBook()); + BookReviewResponse reviewResponse = null; + if (meeting.getBook() != null) { + Long bookId = meeting.getBook().getId(); + reviewResponse = bookReviewRepository.findByBookIdAndUserId(bookId, userId) + .map(BookReviewResponse::from) + .orElse(null); + } + TopicAnswerDetailResponse.PreOpinion preOpinion = + new TopicAnswerDetailResponse.PreOpinion(latestUpdatedAt, topicInfos); - TopicAnswer answer = topicValidator.getTopicAnswer(topicId, userId); + return TopicAnswerDetailResponse.of(bookInfo, reviewResponse, preOpinion); + } - return TopicAnswerDetailResponse.from(answer); + @Transactional + public PreOpinionSaveResponse updateMyAnswer( + Long gatheringId, + Long meetingId, + TopicAnswerBulkSaveRequest request + ) { + return saveAnswersBulk(gatheringId, meetingId, request); } @Transactional - public TopicAnswerResponse updateMyAnswer( + public PreOpinionSubmitResponse submitMyAnswer( Long gatheringId, Long meetingId, - Long topicId, - TopicAnswerRequest request + TopicAnswerBulkSubmitRequest request + ) { + return submitAnswersBulk(gatheringId, meetingId, request); + } + + private PreOpinionSaveResponse saveAnswersBulk( + Long gatheringId, + Long meetingId, + TopicAnswerBulkSaveRequest request ) { Long userId = SecurityUtil.getCurrentUserId(); gatheringValidator.validateMembership(gatheringId, userId); meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - topicValidator.validateTopicInMeeting(topicId, meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); - TopicAnswer answer = topicValidator.getTopicAnswer(topicId, userId); + Map contentsByTopicId = new LinkedHashMap<>(); + for (TopicAnswerBulkSaveRequest.AnswerItem item : request.answers()) { + contentsByTopicId.put(item.topicId(), item.content()); + } - if (Boolean.TRUE.equals(answer.getIsSubmitted())) { - throw new TopicException(TopicErrorCode.TOPIC_ANSWER_ALREADY_SUBMITTED); + List topicIds = List.copyOf(contentsByTopicId.keySet()); + List topics = topicRepository.findAllByIdInAndMeetingId(topicIds, meetingId); + if (topics.size() != topicIds.size()) { + throw new TopicException(TopicErrorCode.TOPIC_NOT_FOUND); } - answer.updateContent(request.content()); + Map topicsById = topics.stream() + .collect(Collectors.toMap(Topic::getId, topic -> topic)); + + Map existingAnswers = topicAnswerRepository.findByMeetingIdUserId(meetingId, userId) + .stream() + .collect(Collectors.toMap(answer -> answer.getTopic().getId(), answer -> answer)); + + User user = SecurityUtil.getCurrentUserEntity(); - return TopicAnswerResponse.from(answer); + BookReviewResponse reviewResponse = upsertReview(meetingId, request.review()); + + List responses = topicIds.stream() + .map(topicId -> { + String content = contentsByTopicId.get(topicId); + TopicAnswer answer = existingAnswers.get(topicId); + if (answer != null) { + if (Boolean.TRUE.equals(answer.getIsSubmitted())) { + throw new TopicException(TopicErrorCode.TOPIC_ANSWER_ALREADY_SUBMITTED); + } + answer.updateContent(content); + return TopicAnswerResponse.from(answer); + } + + Topic topic = topicsById.get(topicId); + TopicAnswer saved = topicAnswerRepository.save(TopicAnswer.create(topic, user, content)); + return TopicAnswerResponse.from(saved); + }) + .toList(); + + return PreOpinionSaveResponse.of(reviewResponse, responses); } - @Transactional - public TopicAnswerSubmitResponse submitMyAnswer( + private PreOpinionSubmitResponse submitAnswersBulk( Long gatheringId, Long meetingId, - Long topicId + TopicAnswerBulkSubmitRequest request ) { Long userId = SecurityUtil.getCurrentUserId(); gatheringValidator.validateMembership(gatheringId, userId); meetingValidator.validateMeetingInGathering(meetingId, gatheringId); - topicValidator.validateTopicInMeeting(topicId, meetingId); + meetingValidator.validateMeetingMember(meetingId, userId); - TopicAnswer answer = topicValidator.getTopicAnswer(topicId, userId); + List topicIds = request.topicIds(); + List topics = topicRepository.findAllByIdInAndMeetingId(topicIds, meetingId); + if (topics.size() != topicIds.size()) { + throw new TopicException(TopicErrorCode.TOPIC_NOT_FOUND); + } - if (Boolean.TRUE.equals(answer.getIsSubmitted())) { - throw new TopicException(TopicErrorCode.TOPIC_ANSWER_ALREADY_SUBMITTED); + Map existingAnswers = topicAnswerRepository.findByMeetingIdUserId(meetingId, userId) + .stream() + .collect(Collectors.toMap(answer -> answer.getTopic().getId(), answer -> answer)); + + for (Long topicId : topicIds) { + TopicAnswer answer = existingAnswers.get(topicId); + if (answer == null) { + throw new TopicException(TopicErrorCode.TOPIC_ANSWER_NOT_FOUND); + } + if (Boolean.TRUE.equals(answer.getIsSubmitted())) { + throw new TopicException(TopicErrorCode.TOPIC_ANSWER_ALREADY_SUBMITTED); + } } - answer.submit(); + BookReviewResponse reviewResponse = upsertReview(meetingId, request.review()); + + List responses = topicIds.stream() + .map(topicId -> { + TopicAnswer answer = existingAnswers.get(topicId); + answer.submit(); + return TopicAnswerSubmitResponse.from(answer); + }) + .toList(); + + return PreOpinionSubmitResponse.of(reviewResponse, responses); + } + + private BookReviewResponse upsertReview(Long meetingId, BookReviewRequest request) { + Long userId = SecurityUtil.getCurrentUserId(); + var meeting = meetingValidator.findMeetingOrThrow(meetingId); + Long bookId = meeting.getBook() != null ? meeting.getBook().getId() : null; + if (bookId == null) { + throw new BookException(BookErrorCode.BOOK_NOT_FOUND); + } - return TopicAnswerSubmitResponse.from(answer); + boolean exists = bookReviewRepository.findByBookIdAndUserId(bookId, userId).isPresent(); + return exists + ? bookReviewService.updateMyReview(bookId, request) + : bookReviewService.createReview(bookId, request); } -} \ No newline at end of file +} diff --git a/src/test/java/com/dokdok/topic/service/TopicAnswerServiceTest.java b/src/test/java/com/dokdok/topic/service/TopicAnswerServiceTest.java index 16e2215e..71ef3682 100644 --- a/src/test/java/com/dokdok/topic/service/TopicAnswerServiceTest.java +++ b/src/test/java/com/dokdok/topic/service/TopicAnswerServiceTest.java @@ -1,18 +1,26 @@ package com.dokdok.topic.service; +import com.dokdok.book.dto.request.BookReviewRequest; +import com.dokdok.book.dto.response.BookReviewResponse; +import com.dokdok.book.repository.BookReviewRepository; +import com.dokdok.book.service.BookReviewService; import com.dokdok.gathering.service.GatheringValidator; +import com.dokdok.meeting.entity.Meeting; import com.dokdok.meeting.service.MeetingValidator; -import com.dokdok.topic.dto.request.TopicAnswerRequest; -import com.dokdok.topic.dto.response.TopicAnswerResponse; +import com.dokdok.topic.dto.request.TopicAnswerBulkSaveRequest; +import com.dokdok.topic.dto.request.TopicAnswerBulkSubmitRequest; +import com.dokdok.topic.dto.response.PreOpinionSaveResponse; +import com.dokdok.topic.dto.response.PreOpinionSubmitResponse; import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicAnswer; import com.dokdok.topic.exception.TopicException; -import com.dokdok.topic.exception.TopicErrorCode; import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.user.entity.User; import com.dokdok.global.exception.GlobalException; import java.util.List; +import java.math.BigDecimal; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -28,9 +36,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -50,7 +57,10 @@ class TopicAnswerServiceTest { private MeetingValidator meetingValidator; @Mock - private TopicValidator topicValidator; + private BookReviewRepository bookReviewRepository; + + @Mock + private BookReviewService bookReviewService; @InjectMocks private TopicAnswerService topicAnswerService; @@ -80,7 +90,7 @@ void clearSecurityContext() { } @Test - @DisplayName("토픽 답변 생성 시 저장 요청과 응답 DTO를 확인한다") + @DisplayName("토픽 답변 일괄 저장 시 저장 요청과 응답 DTO를 확인한다") void createAnswer_savesAnswerAndReturnsResponse() { Topic topic = Topic.builder().id(12L).build(); TopicAnswer saved = TopicAnswer.builder() @@ -90,15 +100,27 @@ void createAnswer_savesAnswerAndReturnsResponse() { .isSubmitted(false) .build(); - given(topicRepository.getReferenceById(12L)).willReturn(topic); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of(topic)); + given(topicAnswerRepository.findByMeetingIdUserId(1L, 1L)) + .willReturn(List.of()); given(topicAnswerRepository.save(any(TopicAnswer.class))) .willReturn(saved); + given(meetingValidator.findMeetingOrThrow(1L)) + .willReturn(Meeting.builder().book(com.dokdok.book.entity.Book.builder().id(10L).build()).build()); + given(bookReviewRepository.findByBookIdAndUserId(10L, 1L)) + .willReturn(Optional.empty()); + BookReviewResponse reviewResponse = new BookReviewResponse(1L, 10L, 1L, BigDecimal.valueOf(4.5), List.of()); + given(bookReviewService.createReview(eq(10L), any(BookReviewRequest.class))) + .willReturn(reviewResponse); + + TopicAnswerBulkSaveRequest request = new TopicAnswerBulkSaveRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(new TopicAnswerBulkSaveRequest.AnswerItem(12L, "이 책을 읽고 ...")) + ); - TopicAnswerRequest request = new TopicAnswerRequest("이 책을 읽고 ..."); - - TopicAnswerResponse response = topicAnswerService.createAnswer( - 1L, 1L, 12L, request + PreOpinionSaveResponse response = topicAnswerService.createAnswer( + 1L, 1L, request ); ArgumentCaptor captor = ArgumentCaptor.forClass(TopicAnswer.class); @@ -109,20 +131,24 @@ void createAnswer_savesAnswerAndReturnsResponse() { assertThat(captured.getUser().getId()).isEqualTo(1L); assertThat(captured.getContent()).isEqualTo("이 책을 읽고 ..."); - assertThat(response.topicId()).isEqualTo(12L); - assertThat(response.isSubmitted()).isFalse(); + assertThat(response.review().reviewId()).isEqualTo(1L); + assertThat(response.answers()).hasSize(1); + assertThat(response.answers().get(0).topicId()).isEqualTo(12L); + assertThat(response.answers().get(0).isSubmitted()).isFalse(); } @Test @DisplayName("토픽이 없으면 예외가 발생한다") void createAnswer_throwsWhenTopicMissing() { - doThrow(new TopicException(TopicErrorCode.TOPIC_NOT_FOUND)) - .when(topicValidator).validateTopicInMeeting(12L, 1L); - - TopicAnswerRequest request = new TopicAnswerRequest("이 책을 읽고 ..."); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of()); + TopicAnswerBulkSaveRequest request = new TopicAnswerBulkSaveRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(new TopicAnswerBulkSaveRequest.AnswerItem(12L, "이 책을 읽고 ...")) + ); assertThatThrownBy(() -> topicAnswerService.createAnswer( - 1L, 1L, 12L, request + 1L, 1L, request )).isInstanceOf(TopicException.class); verifyNoInteractions(topicAnswerRepository); @@ -132,15 +158,18 @@ void createAnswer_throwsWhenTopicMissing() { @DisplayName("인증 정보가 없으면 예외가 발생한다") void createAnswer_throwsWhenUnauthenticated() { SecurityContextHolder.clearContext(); - TopicAnswerRequest request = new TopicAnswerRequest("이 책을 읽고 ..."); + TopicAnswerBulkSaveRequest request = new TopicAnswerBulkSaveRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(new TopicAnswerBulkSaveRequest.AnswerItem(12L, "이 책을 읽고 ...")) + ); assertThatThrownBy(() -> topicAnswerService.createAnswer( - 1L, 1L, 12L, request + 1L, 1L, request )).isInstanceOf(GlobalException.class); } @Test - @DisplayName("내 토픽 답변 수정 시 내용이 갱신되고 응답 DTO를 반환한다") + @DisplayName("토픽 답변 일괄 저장 시 기존 답변이 갱신된다") void updateMyAnswer_updatesContentAndReturnsResponse() { Topic topic = Topic.builder().id(12L).build(); User user = User.builder().id(1L).build(); @@ -152,18 +181,30 @@ void updateMyAnswer_updatesContentAndReturnsResponse() { .isSubmitted(false) .build(); - given(topicValidator.getTopicAnswer(12L, 1L)).willReturn(answer); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); - - TopicAnswerRequest request = new TopicAnswerRequest("수정된 내용"); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of(topic)); + given(topicAnswerRepository.findByMeetingIdUserId(1L, 1L)) + .willReturn(List.of(answer)); + given(meetingValidator.findMeetingOrThrow(1L)) + .willReturn(Meeting.builder().book(com.dokdok.book.entity.Book.builder().id(10L).build()).build()); + given(bookReviewRepository.findByBookIdAndUserId(10L, 1L)) + .willReturn(Optional.empty()); + given(bookReviewService.createReview(eq(10L), any(BookReviewRequest.class))) + .willReturn(new BookReviewResponse(1L, 10L, 1L, BigDecimal.valueOf(4.5), List.of())); + + TopicAnswerBulkSaveRequest request = new TopicAnswerBulkSaveRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(new TopicAnswerBulkSaveRequest.AnswerItem(12L, "수정된 내용")) + ); - TopicAnswerResponse response = topicAnswerService.updateMyAnswer( - 1L, 1L, 12L, request + PreOpinionSaveResponse response = topicAnswerService.updateMyAnswer( + 1L, 1L, request ); assertThat(answer.getContent()).isEqualTo("수정된 내용"); - assertThat(response.topicId()).isEqualTo(12L); - assertThat(response.isSubmitted()).isFalse(); + assertThat(response.answers()).hasSize(1); + assertThat(response.answers().get(0).topicId()).isEqualTo(12L); + assertThat(response.answers().get(0).isSubmitted()).isFalse(); } @Test @@ -179,32 +220,29 @@ void updateMyAnswer_throwsWhenAlreadySubmitted() { .isSubmitted(true) .build(); - given(topicValidator.getTopicAnswer(12L, 1L)).willReturn(answer); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); - - TopicAnswerRequest request = new TopicAnswerRequest("수정된 내용"); - - assertThatThrownBy(() -> topicAnswerService.updateMyAnswer( - 1L, 1L, 12L, request - )).isInstanceOf(TopicException.class); - } - - @Test - @DisplayName("내 토픽 답변이 없으면 수정 시 예외가 발생한다") - void updateMyAnswer_throwsWhenAnswerMissing() { - given(topicValidator.getTopicAnswer(12L, 1L)) - .willThrow(new TopicException(TopicErrorCode.TOPIC_ANSWER_NOT_FOUND)); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); - - TopicAnswerRequest request = new TopicAnswerRequest("수정된 내용"); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of(topic)); + given(topicAnswerRepository.findByMeetingIdUserId(1L, 1L)) + .willReturn(List.of(answer)); + given(meetingValidator.findMeetingOrThrow(1L)) + .willReturn(Meeting.builder().book(com.dokdok.book.entity.Book.builder().id(10L).build()).build()); + given(bookReviewRepository.findByBookIdAndUserId(10L, 1L)) + .willReturn(Optional.empty()); + given(bookReviewService.createReview(eq(10L), any(BookReviewRequest.class))) + .willReturn(new BookReviewResponse(1L, 10L, 1L, BigDecimal.valueOf(4.5), List.of())); + + TopicAnswerBulkSaveRequest request = new TopicAnswerBulkSaveRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(new TopicAnswerBulkSaveRequest.AnswerItem(12L, "수정된 내용")) + ); assertThatThrownBy(() -> topicAnswerService.updateMyAnswer( - 1L, 1L, 12L, request + 1L, 1L, request )).isInstanceOf(TopicException.class); } @Test - @DisplayName("내 토픽 답변 제출 시 제출 상태로 변경된다") + @DisplayName("토픽 답변 일괄 제출 시 제출 상태로 변경된다") void submitMyAnswer_updatesSubmittedState() { Topic topic = Topic.builder().id(12L).build(); User user = User.builder().id(1L).build(); @@ -216,15 +254,28 @@ void submitMyAnswer_updatesSubmittedState() { .isSubmitted(false) .build(); - given(topicValidator.getTopicAnswer(12L, 1L)).willReturn(answer); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); - - com.dokdok.topic.dto.response.TopicAnswerSubmitResponse response = - topicAnswerService.submitMyAnswer(1L, 1L, 12L); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of(topic)); + given(topicAnswerRepository.findByMeetingIdUserId(1L, 1L)) + .willReturn(List.of(answer)); + given(meetingValidator.findMeetingOrThrow(1L)) + .willReturn(Meeting.builder().book(com.dokdok.book.entity.Book.builder().id(10L).build()).build()); + given(bookReviewRepository.findByBookIdAndUserId(10L, 1L)) + .willReturn(Optional.empty()); + given(bookReviewService.createReview(eq(10L), any(BookReviewRequest.class))) + .willReturn(new BookReviewResponse(1L, 10L, 1L, BigDecimal.valueOf(4.5), List.of())); + + TopicAnswerBulkSubmitRequest request = new TopicAnswerBulkSubmitRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(12L) + ); + PreOpinionSubmitResponse response = + topicAnswerService.submitMyAnswer(1L, 1L, request); assertThat(answer.getIsSubmitted()).isTrue(); - assertThat(response.topicId()).isEqualTo(12L); - assertThat(response.isSubmitted()).isTrue(); + assertThat(response.answers()).hasSize(1); + assertThat(response.answers().get(0).topicId()).isEqualTo(12L); + assertThat(response.answers().get(0).isSubmitted()).isTrue(); } @Test @@ -240,12 +291,18 @@ void submitMyAnswer_throwsWhenAlreadySubmitted() { .isSubmitted(true) .build(); - given(topicValidator.getTopicAnswer(12L, 1L)).willReturn(answer); - doNothing().when(topicValidator).validateTopicInMeeting(12L, 1L); + given(topicRepository.findAllByIdInAndMeetingId(List.of(12L), 1L)) + .willReturn(List.of(topic)); + given(topicAnswerRepository.findByMeetingIdUserId(1L, 1L)) + .willReturn(List.of(answer)); + TopicAnswerBulkSubmitRequest request = new TopicAnswerBulkSubmitRequest( + new BookReviewRequest(BigDecimal.valueOf(4.5), List.of(1L)), + List.of(12L) + ); assertThatThrownBy(() -> topicAnswerService.submitMyAnswer( - 1L, 1L, 12L + 1L, 1L, request )).isInstanceOf(TopicException.class); } -} \ No newline at end of file +} From 6f3d7c8bfe41a8bb28506b9c61ce878459e8ae92 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Tue, 10 Feb 2026 23:07:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EB=B9=88=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=A0=9C=EC=B6=9C=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java index 8405d4d0..edd00bb2 100644 --- a/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java +++ b/src/main/java/com/dokdok/topic/dto/request/TopicAnswerBulkSaveRequest.java @@ -3,7 +3,6 @@ import com.dokdok.book.dto.request.BookReviewRequest; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -25,9 +24,8 @@ public record AnswerItem( @NotNull @Schema(description = "토픽 ID", example = "1") Long topicId, - @NotBlank(message = "설명은 필수입니다") @Size(max = 1000, message = "설명은 1000자 이내여야 합니다") - @Schema(description = "답변 내용", example = "이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다.") + @Schema(description = "답변 내용", example = "이 책은 작은 행동의 반복이 인생을 바꾼다고 생각합니다.", nullable = true) String content ) { } From 086504d152598fe77d1419f8c4e71c1b8a25ff61 Mon Sep 17 00:00:00 2001 From: JWoong-01 Date: Tue, 10 Feb 2026 23:24:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=ED=8F=89=EA=B0=80=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EB=94=94=20nullable=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/data/book_review_history_keyword_nullable.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/main/resources/data/book_review_history_keyword_nullable.sql diff --git a/src/main/resources/data/book_review_history_keyword_nullable.sql b/src/main/resources/data/book_review_history_keyword_nullable.sql new file mode 100644 index 00000000..ba5a450e --- /dev/null +++ b/src/main/resources/data/book_review_history_keyword_nullable.sql @@ -0,0 +1,2 @@ +ALTER TABLE book_review_history +ALTER COLUMN keyword_id DROP NOT NULL;