From 5fd60593778d24ebd478471d6abc315b52490784 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 18 Apr 2026 15:16:20 +0900 Subject: [PATCH 01/32] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=20=EC=84=B9=EC=85=98=20=EC=99=B8=20=EB=AC=B8=ED=95=AD=EC=97=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=9C=20=EC=9D=91=EB=8B=B5=EC=9D=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/dto/ParticipationCompletionDto.java | 14 +++++++++++++ .../repository/answer/AnswerRepository.java | 1 + .../answer/QuestionAnswerRepositoryImpl.java | 20 +++++++++++++++++++ .../service/response/ResponseCommand.java | 4 +++- .../response/ResponseCommandService.java | 9 +++++++-- .../controller/ParticipationController.java | 18 +++++++++++++---- .../SurveyParticipationCompletionRequest.java | 8 ++++++++ 7 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java new file mode 100644 index 00000000..9a8939b3 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java @@ -0,0 +1,14 @@ +package OneQ.OnSurvey.domain.participation.model.dto; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record ParticipationCompletionDto( + long surveyId, + long memberId, + long userKey, + List sectionList +) { +} diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java index 4fca5f44..0312e51d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java @@ -40,4 +40,5 @@ default List getRespondentCountsByQuestionIds( ) { return List.of(); } + default void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, List validSectionList) { } } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index e5bf6e43..9662a221 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -11,6 +11,7 @@ import com.querydsl.core.types.dsl.EnumPath; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import org.springframework.stereotype.Repository; @@ -190,6 +191,25 @@ public void deleteAllByIds(Collection answerIds) { .execute(); } + @Override + public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, List validSectionList) { + jpaQueryFactory.delete(questionAnswer) + .where( + questionAnswer.questionId.in(JPAExpressions + .select( + question.questionId + ) + .from(question) + .where( + question.surveyId.eq(surveyId), + question.section.notIn(validSectionList) + ) + ), + questionAnswer.memberId.eq(memberId) + ) + .execute(); + } + private BooleanExpression buildGenderCondition(EnumPath genderPath, List genders) { if (genders == null || genders.isEmpty()) return null; if (genders.contains(Gender.ALL)) return null; diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommand.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommand.java index dfa320f5..61437b92 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommand.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommand.java @@ -1,5 +1,7 @@ package OneQ.OnSurvey.domain.participation.service.response; +import OneQ.OnSurvey.domain.participation.model.dto.ParticipationCompletionDto; + public interface ResponseCommand { - Boolean createResponse(Long surveyId, Long memberId, Long userKey); + Boolean createResponse(ParticipationCompletionDto dto); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index e147d3d5..7ad2a165 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -1,7 +1,10 @@ package OneQ.OnSurvey.domain.participation.service.response; +import OneQ.OnSurvey.domain.participation.entity.QuestionAnswer; import OneQ.OnSurvey.domain.participation.entity.Response; +import OneQ.OnSurvey.domain.participation.model.dto.ParticipationCompletionDto; import OneQ.OnSurvey.domain.participation.model.event.SurveyCompletedEvent; +import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.entity.Survey; @@ -29,6 +32,7 @@ @RequiredArgsConstructor public class ResponseCommandService implements ResponseCommand { + private final AnswerRepository answerRepository; private final ResponseRepository responseRepository; private final SurveyRepository surveyRepository; private final SurveyInfoRepository surveyInfoRepository; @@ -51,7 +55,8 @@ public class ResponseCommandService implements ResponseCommand { private String creatorKey; @Override - public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { + public Boolean createResponse(ParticipationCompletionDto dto) { + long surveyId = dto.surveyId(), memberId = dto.memberId(), userKey = dto.userKey(); try { return redisAgent.executeNewTransactionAfterLock(surveyLockKeyPrefix + surveyId + ":" + userKey, 3, () -> { Response response = responseRepository @@ -62,9 +67,9 @@ public Boolean createResponse(Long surveyId, Long memberId, Long userKey) { throw new CustomException(SurveyErrorCode.SURVEY_ALREADY_PARTICIPATED); } - response.markResponded(); responseRepository.save(response); + answerRepository.deleteInvalidSectionQuestionAnswer(surveyId, memberId, dto.sectionList()); surveyGlobalStatsService.addCompletedCount(1); surveyInfoRepository.increaseCompletedCount(surveyId); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index 23327f22..dc51f3eb 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -3,6 +3,7 @@ import OneQ.OnSurvey.domain.participation.entity.QuestionAnswer; import OneQ.OnSurvey.domain.participation.entity.ScreeningAnswer; import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; +import OneQ.OnSurvey.domain.participation.model.dto.ParticipationCompletionDto; import OneQ.OnSurvey.domain.participation.model.dto.ParticipationStatus; import OneQ.OnSurvey.domain.participation.service.answer.AnswerCommand; import OneQ.OnSurvey.domain.participation.service.response.ResponseCommand; @@ -13,6 +14,7 @@ import OneQ.OnSurvey.domain.survey.model.SurveyStatus; import OneQ.OnSurvey.domain.survey.model.request.InsertQuestionAnswerRequest; import OneQ.OnSurvey.domain.survey.model.request.InsertScreeningAnswerRequest; +import OneQ.OnSurvey.domain.survey.model.request.SurveyParticipationCompletionRequest; import OneQ.OnSurvey.domain.survey.model.response.*; import OneQ.OnSurvey.domain.survey.repository.SurveyRepository; import OneQ.OnSurvey.domain.survey.service.command.SurveyCommandService; @@ -266,12 +268,20 @@ public SuccessResponse createQuestionAnswer( @PostMapping("surveys/{surveyId}/complete") @Operation(summary = "설문 작성을 완료합니다.") public SuccessResponse completeSurvey( - @AuthenticationPrincipal CustomUserDetails principal, - @PathVariable Long surveyId - ) { + @AuthenticationPrincipal CustomUserDetails principal, + @PathVariable Long surveyId, + @RequestBody SurveyParticipationCompletionRequest request + ) { log.info("[PARTICIPATION] 설문 완료 - surveyId: {}, memberId: {}", surveyId, principal.getMemberId()); - Boolean result = responseCommand.createResponse(surveyId, principal.getMemberId(), principal.getUserKey()); + ParticipationCompletionDto dto = ParticipationCompletionDto.builder() + .surveyId(surveyId) + .memberId(principal.getMemberId()) + .userKey(principal.getUserKey()) + .sectionList(request.sectionOrders()) + .build(); + + Boolean result = responseCommand.createResponse(dto); return SuccessResponse.ok(result); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java new file mode 100644 index 00000000..74123e70 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java @@ -0,0 +1,8 @@ +package OneQ.OnSurvey.domain.survey.model.request; + +import java.util.List; + +public record SurveyParticipationCompletionRequest( + List sectionOrders +) { +} From 96280c49f93d371d5b1f3e9f2e2aa4afc6a29c48 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 18 Apr 2026 15:49:43 +0900 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B3=B8=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=AC=B8=ED=95=AD=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EC=97=90=20NOT=20NULL=20=EC=A0=9C=EC=95=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../participation/repository/answer/AnswerRepository.java | 6 +++++- .../repository/answer/QuestionAnswerRepositoryImpl.java | 6 +++++- .../java/OneQ/OnSurvey/domain/question/entity/Question.java | 2 +- .../java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java index 0312e51d..1000b8a2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java @@ -1,7 +1,9 @@ package OneQ.OnSurvey.domain.participation.repository.answer; import OneQ.OnSurvey.domain.participation.model.dto.AnswerStats; +import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.domain.survey.model.SurveyResponseFilterCondition; +import OneQ.OnSurvey.global.common.exception.CustomException; import java.util.Collection; import java.util.List; @@ -40,5 +42,7 @@ default List getRespondentCountsByQuestionIds( ) { return List.of(); } - default void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, List validSectionList) { } + default void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection validSectionList) { + throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_INVALID_SECTION_REMAIN); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index 9662a221..0efb3f87 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -192,7 +192,11 @@ public void deleteAllByIds(Collection answerIds) { } @Override - public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, List validSectionList) { + public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection validSectionList) { + if (validSectionList == null) { + validSectionList = List.of(); + } + jpaQueryFactory.delete(questionAnswer) .where( questionAnswer.questionId.in(JPAExpressions diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java index 7cc148a3..42acde66 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java @@ -39,7 +39,7 @@ public abstract class Question extends BaseEntity { @Builder.Default protected Boolean isRequired = false; - @Column(name = "SECTION") + @Column(name = "SECTION", nullable = false) @ColumnDefault("1") @Builder.Default protected Integer section = 1; diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java index beabc9ce..a3c6c635 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/SurveyErrorCode.java @@ -18,6 +18,7 @@ public enum SurveyErrorCode implements ApiErrorCode { SURVEY_PARTICIPATION_TEMP_EXCEEDED("SURVEY_PARTICIPATION_TEMP_EXCEEDED_409", "설문 참여 가능 인원이 일시적으로 초과되었습니다.", HttpStatus.CONFLICT), SURVEY_PARTICIPATION_OWN_SURVEY("SURVEY_PARTICIPATION_OWN_403", "본인이 생성한 설문에는 참여할 수 없습니다.", HttpStatus.FORBIDDEN), SURVEY_PARTICIPATION_IN_PROCESS("SURVEY_PARTICIPATION_IN_409", "제출한 설문 응답이 처리 중입니다.", HttpStatus.CONFLICT), + SURVEY_PARTICIPATION_INVALID_SECTION_REMAIN("SURVEY_PARTICIPATION_001", "무효 섹션 응답이 제거되지 않습니다.", HttpStatus.BAD_REQUEST), SURVEY_INCORRECT_STATUS("SURVEY_STATUS_400", "요청과 설문 상태가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), SURVEY_FORM_INVALID_QUESTION_TYPE("SURVEY_FORM_QUESTION_TYPE_400", "문항 타입이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), From 2fa83838928ab238ac44b917747952713eb1e87a Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 18 Apr 2026 15:56:44 +0900 Subject: [PATCH 03/32] =?UTF-8?q?mod:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=20=ED=95=84=EB=93=9C=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/dto/ParticipationCompletionDto.java | 2 +- .../participation/repository/answer/AnswerRepository.java | 2 +- .../repository/answer/QuestionAnswerRepositoryImpl.java | 8 ++++---- .../service/response/ResponseCommandService.java | 2 +- .../domain/survey/controller/ParticipationController.java | 2 +- .../request/SurveyParticipationCompletionRequest.java | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java index 9a8939b3..67a267e3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/ParticipationCompletionDto.java @@ -9,6 +9,6 @@ public record ParticipationCompletionDto( long surveyId, long memberId, long userKey, - List sectionList + List visitedSectionList ) { } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java index 1000b8a2..068756a8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java @@ -42,7 +42,7 @@ default List getRespondentCountsByQuestionIds( ) { return List.of(); } - default void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection validSectionList) { + default void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection visitedSectionList) { throw new CustomException(SurveyErrorCode.SURVEY_PARTICIPATION_INVALID_SECTION_REMAIN); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index 0efb3f87..a7ac1f5f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -192,9 +192,9 @@ public void deleteAllByIds(Collection answerIds) { } @Override - public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection validSectionList) { - if (validSectionList == null) { - validSectionList = List.of(); + public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Collection visitedSectionList) { + if (visitedSectionList == null) { + visitedSectionList = List.of(); } jpaQueryFactory.delete(questionAnswer) @@ -206,7 +206,7 @@ public void deleteInvalidSectionQuestionAnswer(long surveyId, long memberId, Col .from(question) .where( question.surveyId.eq(surveyId), - question.section.notIn(validSectionList) + question.section.notIn(visitedSectionList) ) ), questionAnswer.memberId.eq(memberId) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java index 7ad2a165..8b97b757 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/response/ResponseCommandService.java @@ -69,7 +69,7 @@ public Boolean createResponse(ParticipationCompletionDto dto) { response.markResponded(); responseRepository.save(response); - answerRepository.deleteInvalidSectionQuestionAnswer(surveyId, memberId, dto.sectionList()); + answerRepository.deleteInvalidSectionQuestionAnswer(surveyId, memberId, dto.visitedSectionList()); surveyGlobalStatsService.addCompletedCount(1); surveyInfoRepository.increaseCompletedCount(surveyId); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index dc51f3eb..f989c7e4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -278,7 +278,7 @@ public SuccessResponse completeSurvey( .surveyId(surveyId) .memberId(principal.getMemberId()) .userKey(principal.getUserKey()) - .sectionList(request.sectionOrders()) + .visitedSectionList(request.visitedSections()) .build(); Boolean result = responseCommand.createResponse(dto); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java index 74123e70..151e0581 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/SurveyParticipationCompletionRequest.java @@ -3,6 +3,6 @@ import java.util.List; public record SurveyParticipationCompletionRequest( - List sectionOrders + List visitedSections ) { } From 2694eb4ade0723ba8fb2908469984da27ee6022b Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 18 Apr 2026 21:11:39 +0900 Subject: [PATCH 04/32] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=ED=8F=BC=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C,=20=EC=8B=9C=EA=B0=84=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=20=EB=AC=B8=ED=95=AD=20=EB=8C=80=EC=9D=91=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/question/entity/GridOption.java | 39 +++++++++ .../domain/question/entity/question/Grid.java | 83 +++++++++++++++++++ .../domain/question/entity/question/Time.java | 62 ++++++++++++++ .../domain/question/model/QuestionType.java | 8 +- .../question/model/dto/GridOptionDto.java | 25 ++++++ .../question/model/dto/QuestionUpsertDto.java | 9 ++ .../question/model/dto/type/GridDto.java | 38 +++++++++ .../question/model/dto/type/TimeDto.java | 29 +++++++ .../service/QuestionCommandService.java | 48 +++++++++++ 9 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/entity/question/Time.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionDto.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java new file mode 100644 index 00000000..6f79efc4 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java @@ -0,0 +1,39 @@ +package OneQ.OnSurvey.domain.question.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Getter @Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity @Table(name = "GRID_OPTION") +public class GridOption { + + @Id @Column(name = "GRID_OPTION_ID") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long gridOptionId; + + @Column(name = "QUESTION_ID", nullable = false) + private Long questionId; + + @Column(name = "IS_ROW", nullable = false) + @ColumnDefault("FALSE") + @Builder.Default + private Boolean isRow = false; + + @Column + private String content; + + @Column + private Integer order; +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java new file mode 100644 index 00000000..9da2fade --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java @@ -0,0 +1,83 @@ +package OneQ.OnSurvey.domain.question.entity.question; + +import OneQ.OnSurvey.domain.question.entity.Question; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.ColumnDefault; + +@Getter @SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@DiscriminatorValue(value = QuestionType.Values.GRID) +public class Grid extends Question { + + // 객관식 - 체크박스 그리드 + @Column(name = "IS_CHECKBOX") + @ColumnDefault("FALSE") + @Builder.Default + private Boolean isCheckbox = false; + + // 행 순서가 무작위인 문항 + @Column(name = "IS_CHOICE_MIXED") + @ColumnDefault("FALSE") + @Builder.Default + private Boolean isChoiceMixed = false; + + // 각 열당 최대 1개의 응답만 존재할 수 있는 문항 + @Column(name = "IS_CHOICE_DISTINCT") + @ColumnDefault("FALSE") + @Builder.Default + private Boolean isChoiceDistinct = false; + + public static Grid of( + Long surveyId, + Integer order, + String title, + String description, + Boolean isRequired, + Integer section, + QuestionType type, + String imageUrl, + Boolean isCheckbox, + Boolean isChoiceMixed, + Boolean isChoiceDistinct + ) { + return Grid.builder() + .surveyId(surveyId) + .order(order) + .title(title) + .description(description) + .isRequired(isRequired) + .type(type.name()) + .section(section) + .imageUrl(imageUrl) + .isCheckbox(isCheckbox) + .isChoiceMixed(isChoiceMixed) + .isChoiceDistinct(isChoiceDistinct) + .build(); + } + + public void updateQuestion( + String title, + String description, + Boolean isRequired, + Integer order, + Integer section, + String imageUrl, + Boolean isCheckbox, + Boolean isChoiceMixed, + Boolean isChoiceDistinct + ) { + super.updateQuestion(title, description, isRequired, order, section, imageUrl); + this.isCheckbox = isCheckbox; + this.isChoiceMixed = isChoiceMixed; + this.isChoiceDistinct = isChoiceDistinct; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Time.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Time.java new file mode 100644 index 00000000..75adf506 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Time.java @@ -0,0 +1,62 @@ +package OneQ.OnSurvey.domain.question.entity.question; + +import OneQ.OnSurvey.domain.question.entity.Question; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.ColumnDefault; + +@Getter @SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@DiscriminatorValue(value = QuestionType.Values.TIME) +public class Time extends Question { + + @Column(name = "IS_INTERVAL", nullable = false) + @ColumnDefault("FALSE") + @Builder.Default + private Boolean isInterval = false; + + public static Time of( + Long surveyId, + Integer order, + String title, + String description, + Boolean isRequired, + Integer section, + QuestionType type, + String imageUrl, + Boolean isInterval + ) { + return Time.builder() + .surveyId(surveyId) + .order(order) + .title(title) + .description(description) + .isRequired(isRequired) + .type(type.name()) + .section(section) + .imageUrl(imageUrl) + .isInterval(isInterval) + .build(); + } + + public void updateQuestion( + String title, + String description, + Boolean isRequired, + Integer order, + Integer section, + String imageUrl, + Boolean isInterval + ) { + super.updateQuestion(title, description, isRequired, order, section, imageUrl); + this.isInterval = isInterval; + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java index 0b5bafab..c11da4d5 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java @@ -15,7 +15,8 @@ public enum QuestionType { DATE("날짜형", Values.DATE), IMAGE("이미지형", Values.IMAGE), TITLE("제목형", Values.TITLE), - TEXT("주관식", Values.TEXT); + GRID("그리드형", Values.GRID), + TIME("시간형", Values.TIME); private final String description; private final String value; @@ -27,13 +28,14 @@ public static class Values { public static final String LONG = "LONG"; public static final String NUMBER = "NUMBER"; public static final String DATE = "DATE"; - public static final String TEXT = "TEXT"; public static final String IMAGE = "IMAGE"; public static final String TITLE = "TITLE"; + public static final String GRID = "GRID"; + public static final String TIME = "TIME"; } public boolean isText() { - return SHORT.equals(this) || LONG.equals(this) || DATE.equals(this) || TEXT.equals(this) || NUMBER.equals(this); + return SHORT.equals(this) || LONG.equals(this) || DATE.equals(this) || NUMBER.equals(this); } public boolean isChoice() { diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionDto.java new file mode 100644 index 00000000..abf2e99e --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionDto.java @@ -0,0 +1,25 @@ +package OneQ.OnSurvey.domain.question.model.dto; + +import OneQ.OnSurvey.domain.question.entity.GridOption; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter @Builder @ToString +public class GridOptionDto { + private Long gridOptionId; + private Long questionId; + private Boolean isRow; + private String content; + private Integer order; + + public static GridOptionDto fromEntity(GridOption gridOption) { + return GridOptionDto.builder() + .gridOptionId(gridOption.getGridOptionId()) + .questionId(gridOption.getQuestionId()) + .isRow(gridOption.getIsRow()) + .content(gridOption.getContent()) + .order(gridOption.getOrder()) + .build(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java index c8f3c701..66e9c00a 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java @@ -44,6 +44,15 @@ public static class UpsertInfo { // Date 필드 LocalDateTime defaultDate; + + // Grid 필드 + Boolean isCheckbox; + Boolean isChoiceMixed; + Boolean isChoiceDistinct; + List gridOptions; + + // Time 필드 + Boolean isInterval; } public static UpsertInfo fromEntity(Question question) { diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java new file mode 100644 index 00000000..5299b3b7 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java @@ -0,0 +1,38 @@ +package OneQ.OnSurvey.domain.question.model.dto.type; + +import OneQ.OnSurvey.domain.question.entity.question.Grid; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +import java.util.List; + +@Getter @SuperBuilder @ToString(callSuper = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GridDto extends DefaultQuestionDto { + private Boolean isCheckBox; + private Boolean isChoiceMixed; + private Boolean isChoiceDistinct; + + private List gridOptions; + + public static GridDto fromEntity(Grid grid) { + return GridDto.builder() + .isCheckBox(grid.getIsCheckbox()) + .isChoiceMixed(grid.getIsChoiceMixed()) + .isChoiceDistinct(grid.getIsChoiceDistinct()) + .questionId(grid.getQuestionId()) + .surveyId(grid.getSurveyId()) + .questionType(grid.getType()) + .title(grid.getTitle()) + .description(grid.getDescription()) + .isRequired(grid.getIsRequired()) + .questionOrder(grid.getOrder()) + .section(grid.getSection() != null ? grid.getSection() : 1) + .imageUrl(grid.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java new file mode 100644 index 00000000..24c7adba --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java @@ -0,0 +1,29 @@ +package OneQ.OnSurvey.domain.question.model.dto.type; + +import OneQ.OnSurvey.domain.question.entity.question.Time; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; +import lombok.experimental.SuperBuilder; + +@Getter @SuperBuilder @ToString(callSuper = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TimeDto extends DefaultQuestionDto { + private Boolean isInterval; + + public static TimeDto fromEntity(Time time) { + return TimeDto.builder() + .isInterval(time.getIsInterval()) + .questionId(time.getQuestionId()) + .surveyId(time.getSurveyId()) + .questionType(time.getType()) + .title(time.getTitle()) + .description(time.getDescription()) + .isRequired(time.getIsRequired()) + .questionOrder(time.getOrder()) + .section(time.getSection() != null ? time.getSection() : 1) + .imageUrl(time.getImageUrl()) + .build(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 3f45d293..8f8b1114 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -211,6 +211,28 @@ private void updateQuestion(QuestionUpsertDto.UpsertInfo upsertInfo, Question qu upsertInfo.getQuestionOrder(), upsertInfo.getSection() ); + } else if (question instanceof Grid grid) { + grid.updateQuestion( + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getIsRequired(), + upsertInfo.getQuestionOrder(), + upsertInfo.getSection(), + upsertInfo.getImageUrl(), + upsertInfo.getIsCheckbox(), + upsertInfo.getIsChoiceMixed(), + upsertInfo.getIsChoiceDistinct() + ); + } else if (question instanceof Time time) { + time.updateQuestion( + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getIsRequired(), + upsertInfo.getQuestionOrder(), + upsertInfo.getSection(), + upsertInfo.getImageUrl(), + upsertInfo.getIsInterval() + ); } } @@ -321,6 +343,32 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse upsertInfo.getSection(), type ); + } else if (QuestionType.GRID.equals(type)) { + return Grid.of( + surveyId, + upsertInfo.getQuestionOrder(), + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getIsRequired(), + upsertInfo.getSection(), + type, + upsertInfo.getImageUrl(), + upsertInfo.getIsCheckbox(), + upsertInfo.getIsChoiceMixed(), + upsertInfo.getIsChoiceDistinct() + ); + } else if (QuestionType.TIME.equals(type)) { + return Time.of( + surveyId, + upsertInfo.getQuestionOrder(), + upsertInfo.getTitle(), + upsertInfo.getDescription(), + upsertInfo.getIsRequired(), + upsertInfo.getSection(), + type, + upsertInfo.getImageUrl(), + upsertInfo.getIsInterval() + ); } else { throw new CustomException(ErrorCode.INVALID_REQUEST); } From 28b910763675f892c65dbc179adf62523dc03d99 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 24 Apr 2026 09:39:40 +0900 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C/=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=EC=97=90=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C,=20=EC=8B=9C=EA=B0=84=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=AC=B8=ED=95=AD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/model/survey/SurveyQuestion.java | 22 ++- .../admin/infra/mapper/AdminSurveyMapper.java | 27 ++++ .../domain/question/entity/GridOption.java | 16 +- .../domain/question/entity/Question.java | 4 + .../domain/question/model/QuestionType.java | 6 +- .../model/dto/GridOptionUpsertDto.java | 13 ++ .../question/model/dto/QuestionUpsertDto.java | 24 ++- .../model/dto/type/DefaultQuestionDto.java | 11 +- .../question/model/dto/type/GridDto.java | 4 + .../dto/type/QuestionTypeAndInfoDto.java | 2 +- .../gridOption/GridOptionJpaRepository.java | 13 ++ .../gridOption/GridOptionRepository.java | 14 ++ .../gridOption/GridOptionRepositoryImpl.java | 58 +++++++ .../question/service/QuestionCommand.java | 2 + .../service/QuestionCommandService.java | 104 ++++++++++++- .../question/service/QuestionConverter.java | 34 +++-- .../question/service/QuestionQuery.java | 2 + .../service/QuestionQueryService.java | 72 +++++---- .../survey/service/form/SurveyFormFacade.java | 144 +++++++++++------- .../service/formRequest/FormConverter.java | 84 ++++++---- 20 files changed, 526 insertions(+), 130 deletions(-) create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionUpsertDto.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionJpaRepository.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepository.java create mode 100644 src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepositoryImpl.java diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java index 2d0cbc75..a35da5e0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java @@ -18,7 +18,9 @@ public record SurveyQuestion( ChoiceProp choiceProperty, RatingProp ratingProperty, - DateProp dateProperty + DateProp dateProperty, + GridProp gridProperty, + TimeProp timeProperty ) { public record ChoiceProp ( Integer maxChoice, @@ -44,4 +46,22 @@ public record RatingProp ( public record DateProp ( LocalDate defaultDate ) {} + + public record GridProp ( + Boolean isCheckbox, + Boolean isChoiceMixed, + Boolean isChoiceDistinct, + + Set gridOptions + ) { + public record GridOption ( + Boolean isRow, + String content, + Integer order + ) {} + } + + public record TimeProp ( + Boolean isInterval + ) {} } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java index 32e7ac38..84deff99 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java @@ -10,7 +10,9 @@ import OneQ.OnSurvey.domain.question.model.dto.type.ChoiceDto; import OneQ.OnSurvey.domain.question.model.dto.type.DateDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.GridDto; import OneQ.OnSurvey.domain.question.model.dto.type.RatingDto; +import OneQ.OnSurvey.domain.question.model.dto.type.TimeDto; import OneQ.OnSurvey.domain.survey.model.SurveyStatus; import OneQ.OnSurvey.domain.survey.model.dto.ScreeningViewData; import OneQ.OnSurvey.domain.survey.model.dto.SurveyDetailData; @@ -116,6 +118,31 @@ public static SurveyQuestion toSurveyQuestion(DefaultQuestionDto questionDto) { ) ).build(); } + case "GRID" -> { + GridDto grid = (GridDto) questionDto; + yield question.gridProperty( + new SurveyQuestion.GridProp( + grid.getIsCheckBox(), + grid.getIsChoiceMixed(), + grid.getIsChoiceDistinct(), + grid.getGridOptions().stream() + .map(gridOptionDto -> new SurveyQuestion.GridProp.GridOption( + gridOptionDto.getIsRow(), + gridOptionDto.getContent(), + gridOptionDto.getOrder() + )) + .collect(Collectors.toSet()) + ) + ).build(); + } + case "TIME" -> { + TimeDto time = (TimeDto) questionDto; + yield question.timeProperty( + new SurveyQuestion.TimeProp( + time.getIsInterval() + ) + ).build(); + } default -> question.build(); }; } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java index 6f79efc4..93ea4fe4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java @@ -34,6 +34,20 @@ public class GridOption { @Column private String content; - @Column + @Column(name = "GRID_ORDER") private Integer order; + + public static GridOption of(Long questionId, Boolean isRow, String content, Integer order) { + return GridOption.builder() + .questionId(questionId) + .isRow(isRow) + .content(content) + .order(order) + .build(); + } + + public void updateGridOption(String content, Integer order) { + this.content = content; + this.order = order; + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java index 7cc148a3..5939dc0d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java @@ -70,4 +70,8 @@ public void updateOrder(Integer order) { public boolean isChoice() { return QuestionType.CHOICE.equals(QuestionType.valueOf(this.type)); } + + public QuestionType getQuestionType() { + return QuestionType.valueOf(this.type); + } } \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java index c11da4d5..f9d0b6fa 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/QuestionType.java @@ -35,10 +35,14 @@ public static class Values { } public boolean isText() { - return SHORT.equals(this) || LONG.equals(this) || DATE.equals(this) || NUMBER.equals(this); + return SHORT.equals(this) || LONG.equals(this) || DATE.equals(this) || NUMBER.equals(this) || TIME.equals(this); } public boolean isChoice() { return CHOICE.equals(this); } + + public boolean isGrid() { + return GRID.equals(this); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionUpsertDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionUpsertDto.java new file mode 100644 index 00000000..b7fd6e00 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/GridOptionUpsertDto.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.question.model.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.util.List; + +@Getter @Builder @ToString +public class GridOptionUpsertDto { + private Long questionId; + private List gridOptionInfoList; +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java index 66e9c00a..e8040801 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/QuestionUpsertDto.java @@ -3,11 +3,12 @@ import OneQ.OnSurvey.domain.question.entity.Question; import OneQ.OnSurvey.domain.question.entity.question.Choice; import OneQ.OnSurvey.domain.question.entity.question.DateAnswer; +import OneQ.OnSurvey.domain.question.entity.question.Grid; import OneQ.OnSurvey.domain.question.entity.question.Rating; +import OneQ.OnSurvey.domain.question.entity.question.Time; import OneQ.OnSurvey.domain.question.model.QuestionType; import lombok.Builder; import lombok.Getter; -import lombok.Setter; import lombok.ToString; import java.time.LocalDateTime; @@ -34,7 +35,6 @@ public static class UpsertInfo { Boolean hasNoneOption; Boolean hasCustomInput; Boolean isSectionDecidable; - @Setter List options; // Rating 필드 @@ -53,6 +53,14 @@ public static class UpsertInfo { // Time 필드 Boolean isInterval; + + public void updateOptions(List options) { + this.options = options; + } + + public void updateGridOptions(List gridOptions) { + this.gridOptions = gridOptions; + } } public static UpsertInfo fromEntity(Question question) { @@ -88,6 +96,18 @@ public static UpsertInfo fromEntity(Question question) { DateAnswer dateAnswer = (DateAnswer) question; yield builder.defaultDate(dateAnswer.getDefaultDate()).build(); } + case GRID -> { + Grid grid = (Grid) question; + yield builder + .isCheckbox(grid.getIsCheckbox()) + .isChoiceMixed(grid.getIsChoiceMixed()) + .isChoiceDistinct(grid.getIsChoiceDistinct()) + .build(); + } + case TIME -> { + Time time = (Time) question; + yield builder.isInterval(time.getIsInterval()).build(); + } default -> builder.build(); }; } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java index 8fbe3ca2..b7610184 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java @@ -25,6 +25,8 @@ @JsonSubTypes.Type(value = ChoiceDto.class, name = "CHOICE"), @JsonSubTypes.Type(value = RatingDto.class, name = "RATING"), @JsonSubTypes.Type(value = DateDto.class, name = "DATE"), + @JsonSubTypes.Type(value = GridDto.class, name = "GRID"), + @JsonSubTypes.Type(value = TimeDto.class, name = "TIME") }) public class DefaultQuestionDto { private Long questionId; @@ -33,7 +35,7 @@ public class DefaultQuestionDto { @Schema( description = "문항 타입 유형", allowableValues = { - "CHOICE", "RATING", "NPS", "SHORT", "LONG", "NUMBER", "DATE" + "CHOICE", "RATING", "NPS", "SHORT", "LONG", "NUMBER", "DATE", "IMAGE", "TITLE", "GRID", "TIME" } ) private String questionType; @@ -60,6 +62,11 @@ public static DefaultQuestionDto fromEntity(Question question) { @JsonIgnore public boolean isChoice() { - return QuestionType.valueOf(this.questionType).isChoice(); + return QuestionType.CHOICE.equals(QuestionType.valueOf(this.questionType)); + } + + @JsonIgnore + public boolean isGrid() { + return QuestionType.GRID.equals(QuestionType.valueOf(this.questionType)); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java index 5299b3b7..f0bcbaa0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java @@ -35,4 +35,8 @@ public static GridDto fromEntity(Grid grid) { .imageUrl(grid.getImageUrl()) .build(); } + + public void updateOptions(List gridOptions) { + this.gridOptions = gridOptions; + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/QuestionTypeAndInfoDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/QuestionTypeAndInfoDto.java index 05e4b166..7bc388b2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/QuestionTypeAndInfoDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/QuestionTypeAndInfoDto.java @@ -12,7 +12,7 @@ public class QuestionTypeAndInfoDto { @Schema( description = "문항 타입 유형", allowableValues = { - "CHOICE", "RATING", "NPS", "SHORT", "LONG", "NUMBER", "DATE" + "CHOICE", "RATING", "NPS", "SHORT", "LONG", "NUMBER", "DATE", "IMAGE", "TITLE", "GRID", "TIME" } ) private QuestionType questionType; diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionJpaRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionJpaRepository.java new file mode 100644 index 00000000..216fedf9 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionJpaRepository.java @@ -0,0 +1,13 @@ +package OneQ.OnSurvey.domain.question.repository.gridOption; + +import OneQ.OnSurvey.domain.question.entity.GridOption; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface GridOptionJpaRepository extends JpaRepository { + List getGridOptionsByQuestionIdIsInOrderByGridOptionIdAsc(Collection questionIds); + + List getGridOptionsByQuestionId(Long questionId); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepository.java new file mode 100644 index 00000000..7df3657b --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepository.java @@ -0,0 +1,14 @@ +package OneQ.OnSurvey.domain.question.repository.gridOption; + +import OneQ.OnSurvey.domain.question.entity.GridOption; + +import java.util.Collection; +import java.util.List; + +public interface GridOptionRepository { + List getGridOptionsByQuestionIds(Collection questionIds); + List getGridOptionsByQuestionId(Long questionId); + List saveAll(Collection gridOptions); + void deleteAll(Collection ids); + void deleteBySections(Long surveyId, Collection sections); +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepositoryImpl.java new file mode 100644 index 00000000..bf3ed9c0 --- /dev/null +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/gridOption/GridOptionRepositoryImpl.java @@ -0,0 +1,58 @@ +package OneQ.OnSurvey.domain.question.repository.gridOption; + +import OneQ.OnSurvey.domain.question.entity.GridOption; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +import static OneQ.OnSurvey.domain.question.entity.QQuestion.question; +import static OneQ.OnSurvey.domain.question.entity.QGridOption.gridOption; + +@Repository +@RequiredArgsConstructor +public class GridOptionRepositoryImpl implements GridOptionRepository { + + private final GridOptionJpaRepository gridOptionJpaRepository; + private final JPAQueryFactory jpaQueryFactory; + + @Override + public List getGridOptionsByQuestionIds(Collection questionIds) { + return gridOptionJpaRepository.getGridOptionsByQuestionIdIsInOrderByGridOptionIdAsc(questionIds); + } + + @Override + public List getGridOptionsByQuestionId(Long questionId) { + return gridOptionJpaRepository.getGridOptionsByQuestionId(questionId); + } + + @Override + public List saveAll(Collection gridOptions) { + return gridOptionJpaRepository.saveAllAndFlush(gridOptions); + } + + @Override + public void deleteAll(Collection ids) { + gridOptionJpaRepository.deleteAllByIdInBatch(ids); + } + + @Override + public void deleteBySections(Long surveyId, Collection sections) { + jpaQueryFactory.delete( + gridOption + ).where( + gridOption.questionId.in( + JPAExpressions + .select(question.questionId) + .from(question) + .where( + question.surveyId.eq(surveyId), + question.section.notIn(sections) + ) + ) + ).execute(); + } +} diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommand.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommand.java index 1a120f51..cb0fd8c2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommand.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommand.java @@ -1,5 +1,6 @@ package OneQ.OnSurvey.domain.question.service; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.SectionDto; @@ -9,5 +10,6 @@ public interface QuestionCommand { QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto); List upsertChoiceOptionList(List upsertDtoList); + List upsertGridOptionList(List upsertDtoList); List upsertSections(Long surveyId, List sectionDtoList); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 8f8b1114..8a473f54 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -1,15 +1,19 @@ package OneQ.OnSurvey.domain.question.service; import OneQ.OnSurvey.domain.question.entity.ChoiceOption; +import OneQ.OnSurvey.domain.question.entity.GridOption; import OneQ.OnSurvey.domain.question.entity.Question; import OneQ.OnSurvey.domain.question.entity.Section; import OneQ.OnSurvey.domain.question.entity.question.*; import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.SectionDto; import OneQ.OnSurvey.domain.question.repository.choiceOption.ChoiceOptionRepository; +import OneQ.OnSurvey.domain.question.repository.gridOption.GridOptionRepository; import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository; import OneQ.OnSurvey.domain.question.repository.section.SectionRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; @@ -30,9 +34,10 @@ @Transactional public class QuestionCommandService implements QuestionCommand { - private final QuestionRepository questionRepository; private final ChoiceOptionRepository choiceOptionRepository; + private final GridOptionRepository gridOptionRepository; private final SectionRepository sectionRepository; + private final QuestionRepository questionRepository; @Override public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { @@ -70,13 +75,20 @@ public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { // 문항 삭제 전에 해당 문항의 보기(ChoiceOption)도 삭제 List optionsToDelete = choiceOptionRepository.getOptionsByQuestionIds(deleteIdSet); + List gridOptionsToDelete = gridOptionRepository.getGridOptionsByQuestionIds(deleteIdSet); if (!optionsToDelete.isEmpty()) { Set optionIdsToDelete = optionsToDelete.stream() .map(ChoiceOption::getChoiceOptionId) .collect(Collectors.toSet()); - log.info("[QUESTION:COMMAND:upsertQuestionList] 삭제되는 보기 IDs: {}", optionIdsToDelete); choiceOptionRepository.deleteAll(optionIdsToDelete); } + if (!gridOptionsToDelete.isEmpty()) { + Set gridOptionIdsToDelete = gridOptionsToDelete.stream() + .map(GridOption::getGridOptionId) + .collect(Collectors.toSet()); + log.info("[QUESTION:COMMAND:upsertQuestionList] 삭제되는 보기 IDs: {}", gridOptionIdsToDelete); + gridOptionRepository.deleteAll(gridOptionIdsToDelete); + } questionRepository.deleteAll(deleteIdSet); log.info("[QUESTION:COMMAND:upsertQuestionList] DELETE 진행"); @@ -464,6 +476,93 @@ public List upsertChoiceOptionList(List upsert .toList(); } + @Override + public List upsertGridOptionList(List upsertDtoList) { + log.info("[QUESTION:COMMAND:upsertGridOptionList] 그리드 문항 행/열 UPSERT"); + + List finalList = new ArrayList<>(); + for (GridOptionUpsertDto upsertDto : upsertDtoList) { + Long questionId = upsertDto.getQuestionId(); + List requestInfos = upsertDto.getGridOptionInfoList(); + + // 1. DB 저장 보기 전체 조회 + List prevOptionList = gridOptionRepository.getGridOptionsByQuestionId(questionId); + + // 2. Insert/Update 데이터 파티셔닝 + Map> partitionUpsertInfoList = requestInfos.stream() + .collect(Collectors.partitioningBy(info -> info.getGridOptionId() != null)); + + List newInfoList = partitionUpsertInfoList.get(false); + List updateInfoList = partitionUpsertInfoList.get(true); + + // 3. Update 대상 ID 추출 + Set updateIdSet = updateInfoList.stream() + .map(GridOptionDto::getGridOptionId) + .collect(Collectors.toSet()); + + log.info("[QUESTION:COMMAND:upsertGridOptionList] 수정되는 문항: {}, 보기 IDs: {}", questionId, updateIdSet); + log.info("[QUESTION:COMMAND:upsertGridOptionList] 생성되는 문항: {}, 보기 개수: {}", questionId, newInfoList.size()); + + // 4. Delete 대상 ID 추출 및 삭제 + Set deleteIdSet = prevOptionList.stream() + .map(GridOption::getGridOptionId) + .filter(optionId -> !updateIdSet.contains(optionId)) + .collect(Collectors.toSet()); + if (!deleteIdSet.isEmpty()) { + log.info("[QUESTION:COMMAND:upsertGridOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); + gridOptionRepository.deleteAll(deleteIdSet); + } + + // 5. Update 대상 수정 + Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( + GridOptionDto::getGridOptionId, + Function.identity(), + (existing, replace) -> existing + )); + List updateList = prevOptionList.stream() + .filter(option -> updateIdSet.contains(option.getGridOptionId())) + .toList(); + + updateList.forEach(option -> { + Long id = option.getGridOptionId(); + GridOptionDto optionInfo = updateInfoMap.get(id); + option.updateGridOption( + optionInfo.getContent(), + optionInfo.getOrder() + ); + }); + + // 6. Insert 대상 객체 생성 + List insertList = newInfoList.stream() + .map(upsertInfo -> GridOption.of( + questionId, + upsertInfo.getIsRow(), + upsertInfo.getContent(), + upsertInfo.getOrder() + )).toList(); + + finalList.addAll(updateList); + finalList.addAll(insertList); + } + + // 7. Update/Insert 진행 + List optionList = gridOptionRepository.saveAll(finalList); + log.info("[QUESTION:COMMAND:upsertGridOptionList] UPSERT 완료"); + + // 8. 반환값 구성 + Map> idOptionListMap = optionList.stream().collect(Collectors.groupingBy(GridOption::getQuestionId)); + return idOptionListMap.entrySet().stream().map(entry -> { + Long questionId = entry.getKey(); + List savedList = entry.getValue(); + + return GridOptionUpsertDto.builder() + .questionId(questionId) + .gridOptionInfoList(savedList.stream().map(GridOptionDto::fromEntity).toList()) + .build(); + }) + .toList(); + } + @Override public List upsertSections(Long surveyId, List sectionDtoList) { if (!sectionDtoList.stream().allMatch(SectionDto::isValid)) { @@ -503,6 +602,7 @@ public List upsertSections(Long surveyId, List sectionDt if (!savedSectionList.isEmpty()) { Set savedSections = savedSectionList.stream().map(Section::getSectionOrder).collect(Collectors.toSet()); choiceOptionRepository.deleteBySections(surveyId, savedSections); + gridOptionRepository.deleteBySections(surveyId, savedSections); questionRepository.deleteBySurveyIdAndNotInOrder(surveyId, savedSections); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java index 8f625d13..94bc03e3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java @@ -3,14 +3,19 @@ import OneQ.OnSurvey.domain.question.entity.Question; import OneQ.OnSurvey.domain.question.entity.question.Choice; import OneQ.OnSurvey.domain.question.entity.question.DateAnswer; +import OneQ.OnSurvey.domain.question.entity.question.Grid; import OneQ.OnSurvey.domain.question.entity.question.Rating; +import OneQ.OnSurvey.domain.question.entity.question.Time; import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.type.ChoiceDto; import OneQ.OnSurvey.domain.question.model.dto.type.DateDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.GridDto; import OneQ.OnSurvey.domain.question.model.dto.type.RatingDto; +import OneQ.OnSurvey.domain.question.model.dto.type.TimeDto; import java.util.List; @@ -61,6 +66,18 @@ private static QuestionUpsertDto.UpsertInfo toUpsertInfo(DefaultQuestionDto dto) .maxValue(ratingDto.getMaxValue()) .rate(ratingDto.getRate()); case DateDto dateDto -> builder.defaultDate(dateDto.getDate()); + case GridDto gridDto -> builder.isCheckbox(gridDto.getIsCheckBox()) + .isChoiceMixed(gridDto.getIsChoiceMixed() != null ? gridDto.getIsChoiceMixed() : false) + .isChoiceDistinct(gridDto.getIsChoiceDistinct() != null ? gridDto.getIsChoiceDistinct() : false) + .gridOptions(gridDto.getGridOptions().stream().map(option -> + GridOptionDto.builder() + .gridOptionId(option.getGridOptionId()) + .isRow(option.getIsRow()) + .content(option.getContent()) + .order(option.getOrder()).build() + ).toList() + ); + case TimeDto timeDto -> builder.isInterval(timeDto.getIsInterval() != null ? timeDto.getIsInterval() : false); default -> { } } @@ -69,14 +86,13 @@ private static QuestionUpsertDto.UpsertInfo toUpsertInfo(DefaultQuestionDto dto) } public static DefaultQuestionDto toQuestionDto(Question question) { - if (question instanceof Choice choice) { - return ChoiceDto.fromEntity(choice); - } else if (question instanceof Rating rating) { - return RatingDto.fromEntity(rating); - } else if (question instanceof DateAnswer dateAnswer) { - return DateDto.fromEntity(dateAnswer); - } else { - return DefaultQuestionDto.fromEntity(question); - } + return switch (question) { + case Choice choice -> ChoiceDto.fromEntity(choice); + case Rating rating -> RatingDto.fromEntity(rating); + case DateAnswer dateAnswer -> DateDto.fromEntity(dateAnswer); + case Grid grid -> GridDto.fromEntity(grid); + case Time time -> TimeDto.fromEntity(time); + default -> DefaultQuestionDto.fromEntity(question); + }; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java index e399c7fe..be2b4a4d 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQuery.java @@ -1,5 +1,6 @@ package OneQ.OnSurvey.domain.question.service; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; @@ -7,6 +8,7 @@ public interface QuestionQuery { List getOptionsByQuestionIdList(List questionIdList); + List getGridOptionsByQuestionIdList(List questionIdList); List getQuestionDtoListBySurveyId(Long surveyId); List getQuestionDtoListBySurveyIdAndSection(Long surveyId, Integer section); int countQuestionsBySurveyId(Long surveyId); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java index 9a5188ec..13914fb3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java @@ -1,11 +1,16 @@ package OneQ.OnSurvey.domain.question.service; import OneQ.OnSurvey.domain.question.entity.ChoiceOption; +import OneQ.OnSurvey.domain.question.entity.GridOption; import OneQ.OnSurvey.domain.question.entity.Question; +import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.type.ChoiceDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.GridDto; import OneQ.OnSurvey.domain.question.repository.choiceOption.ChoiceOptionRepository; +import OneQ.OnSurvey.domain.question.repository.gridOption.GridOptionRepository; import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,6 +30,7 @@ public class QuestionQueryService implements QuestionQuery { private final QuestionRepository questionRepository; private final ChoiceOptionRepository choiceOptionRepository; + private final GridOptionRepository gridOptionRepository; @Override public List getOptionsByQuestionIdList(List questionIdList) { @@ -33,12 +39,19 @@ public List getOptionsByQuestionIdList(List questionIdList) { return optionList.stream().map(OptionDto::fromEntity).toList(); } + @Override + public List getGridOptionsByQuestionIdList(List questionIdList) { + List gridOptionList = gridOptionRepository.getGridOptionsByQuestionIds(questionIdList); + + return gridOptionList.stream().map(GridOptionDto::fromEntity).toList(); + } + @Override public List getQuestionDtoListBySurveyId(Long surveyId) { List questionList = questionRepository.getQuestionListBySurveyId(surveyId); log.info("[QUESTION:QUERY:getQuestionDtoListBySurveyId] 조회할 설문 문항 IDs: {}", questionList.stream().map(Question::getQuestionId).toList()); - return fillChoiceOptions(questionList); + return fillOptions(questionList); } @Override @@ -46,7 +59,7 @@ public List getQuestionDtoListBySurveyIdAndSection(Long surv List questionList = questionRepository.getQuestionListBySurveyIdAndSection(surveyId, section); log.info("[QUESTION:QUERY:getQuestionDtoListBySurveyIdAndSection] 조회할 설문 문항 IDs: {}", questionList.stream().map(Question::getQuestionId).toList()); - return fillChoiceOptions(questionList); + return fillOptions(questionList); } @Override @@ -54,31 +67,38 @@ public int countQuestionsBySurveyId(Long surveyId) { return questionRepository.countBySurveyId(surveyId); } - private List fillChoiceOptions(List questionList) { - - Set choiceIdSet = questionList.stream() - .filter(Question::isChoice) - .map(Question::getQuestionId) - .collect(Collectors.toSet()); - log.info("[QUESTION:QUERY:fillChoiceOptions] 선택형 설문 문항 IDs: {}", choiceIdSet); - - List totalOptionList = choiceIdSet.isEmpty() ? - List.of() : choiceOptionRepository.getOptionsByQuestionIds(choiceIdSet); - Map> questionIdChoiceOptionMap = totalOptionList.stream() - .collect(Collectors.groupingBy(ChoiceOption::getQuestionId)); - - List questionDtoList = questionList.stream() + private List fillOptions(List questionList) { + Map> typeIdMap = questionList.stream() + .filter(q -> QuestionType.CHOICE.equals(q.getQuestionType()) || QuestionType.GRID.equals(q.getQuestionType())) + .collect(Collectors.groupingBy( + Question::getQuestionType, + Collectors.mapping(Question::getQuestionId, Collectors.toSet()) + )); + + Map> choiceIdOptionMap = typeIdMap.getOrDefault(QuestionType.CHOICE, Set.of()).isEmpty() + ? Map.of() + : choiceOptionRepository.getOptionsByQuestionIds(typeIdMap.get(QuestionType.CHOICE)) + .stream() + .collect(Collectors.groupingBy(ChoiceOption::getQuestionId)); + Map> gridIdOptionMap = typeIdMap.getOrDefault(QuestionType.GRID, Set.of()).isEmpty() + ? Map.of() + : gridOptionRepository.getGridOptionsByQuestionIds(typeIdMap.get(QuestionType.GRID)) + .stream() + .collect(Collectors.groupingBy(GridOption::getQuestionId)); + + return questionList.stream() .map(QuestionConverter::toQuestionDto) + .peek(dto -> { + if (dto.isChoice()) { + ChoiceDto choiceDto = (ChoiceDto) dto; + List optionList = choiceIdOptionMap.getOrDefault(dto.getQuestionId(), List.of()); + choiceDto.updateOptions(optionList.stream().map(OptionDto::fromEntity).toList()); + } else if (dto.isGrid()) { + GridDto gridDto = (GridDto) dto; + List gridOptionList = gridIdOptionMap.getOrDefault(dto.getQuestionId(), List.of()); + gridDto.updateOptions(gridOptionList.stream().map(GridOptionDto::fromEntity).toList()); + } + }) .toList(); - - questionDtoList.forEach(dto -> { - if (dto.isChoice()) { - ChoiceDto choiceDto = (ChoiceDto) dto; - List optionList = questionIdChoiceOptionMap.getOrDefault(dto.getQuestionId(), List.of()); - choiceDto.updateOptions(optionList.stream().map(OptionDto::fromEntity).toList()); - } - }); - - return questionDtoList; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java index 7f0f29f3..f5bafc73 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.survey.service.form; import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.SectionDto; @@ -17,10 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -71,19 +71,23 @@ public UpdateQuestionResponse upsertQuestions(Long surveyId, QuestionRequest req validateUpsertQuestionsRequest(request); - QuestionUpsertDto requestQuestionUpsertDto = - QuestionConverter.toQuestionUpsertDto(surveyId, request.getQuestions()); + QuestionUpsertDto requestQuestionUpsertDto = QuestionConverter.toQuestionUpsertDto(surveyId, request.getQuestions()); + QuestionUpsertDto savedQuestionUpsertDto = questionCommand.upsertQuestionList(requestQuestionUpsertDto); - QuestionUpsertDto savedQuestionUpsertDto = - questionCommand.upsertQuestionList(requestQuestionUpsertDto); + OptionUpsertBundle optionUpsertBundle = buildOptionUpsertDtos(savedQuestionUpsertDto, requestQuestionUpsertDto); + List optionUpsertDtoList = optionUpsertBundle.choiceOptionUpsertDtos(); + List gridOptionUpsertDtoList = optionUpsertBundle.gridOptionUpsertDtos(); - List optionUpsertDtoList = - buildOptionUpsertDtosFromSavedQuestions(savedQuestionUpsertDto, requestQuestionUpsertDto); - - optionUpsertDtoList = questionCommand.upsertChoiceOptionList(optionUpsertDtoList); + if (!optionUpsertDtoList.isEmpty()) { + optionUpsertDtoList = questionCommand.upsertChoiceOptionList(optionUpsertDtoList); + } + if (!gridOptionUpsertDtoList.isEmpty()) { + gridOptionUpsertDtoList = questionCommand.upsertGridOptionList(gridOptionUpsertDtoList); + } Map optionDtoMap = mapOptionsByQuestionId(optionUpsertDtoList); + Map gridOptionDtoMap = mapGridOptionsByQuestionId(gridOptionUpsertDtoList); - applyOptionsToQuestionUpsertDto(savedQuestionUpsertDto, optionDtoMap); + applyOptionsToQuestionUpsertDto(savedQuestionUpsertDto, optionDtoMap, gridOptionDtoMap); return new UpdateQuestionResponse( savedQuestionUpsertDto.getSurveyId(), @@ -178,67 +182,91 @@ private void validateUpsertQuestionsRequest(QuestionRequest request) { } } - /** UPSERT 이후 CHOICE 타입 문항에 대해 questionId -> UpsertInfo 맵 생성 */ - private Map buildChoiceQuestionMap(QuestionUpsertDto questionUpsertDto) { - return questionUpsertDto.getUpsertInfoList().stream() - .filter(info -> QuestionType.CHOICE.equals(info.getQuestionType())) - .filter(info -> info.getQuestionId() != null) // 🔹 null key 방지 - .collect(Collectors.toMap( - QuestionUpsertDto.UpsertInfo::getQuestionId, - Function.identity() - )); - } - - /** UPSERT된 QuestionUpsertDto에서 CHOICE 문항별 OptionUpsertDto 리스트 생성 */ - private List buildOptionUpsertDtosFromSavedQuestions( + /** CHOICE/GRID 옵션 UPSERT DTO를 동시에 구성 */ + private OptionUpsertBundle buildOptionUpsertDtos( QuestionUpsertDto savedQuestionUpsertDto, QuestionUpsertDto requestedQuestionUpsertDto ) { - Set choiceQuestionIdSet = getChoiceQuestionIds(savedQuestionUpsertDto); - Map requestChoiceQuestionMap = buildChoiceQuestionMap(requestedQuestionUpsertDto); - - return choiceQuestionIdSet.stream() - .map(qId -> OptionUpsertDto.builder() - .questionId(qId) - .optionInfoList( - (requestChoiceQuestionMap.get(qId) != null - && requestChoiceQuestionMap.get(qId).getOptions() != null) - ? requestChoiceQuestionMap.get(qId).getOptions() - : List.of() - ) - .build()) - .toList(); - } - - private Set getChoiceQuestionIds(QuestionUpsertDto questionUpsertDto) { - return questionUpsertDto.getUpsertInfoList().stream() - .filter(info -> QuestionType.CHOICE.equals(info.getQuestionType())) - .map(QuestionUpsertDto.UpsertInfo::getQuestionId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); + Map requestedByOrder = requestedQuestionUpsertDto.getUpsertInfoList().stream() + .filter(info -> info.getQuestionOrder() != null) + .collect(Collectors.toMap( + QuestionUpsertDto.UpsertInfo::getQuestionOrder, + Function.identity(), + (existing, replace) -> existing + )); + + List choiceOptionUpsertDtos = new ArrayList<>(); + List gridOptionUpsertDtos = new ArrayList<>(); + + savedQuestionUpsertDto.getUpsertInfoList().stream() + .filter(savedInfo -> savedInfo.getQuestionType().isChoice() || savedInfo.getQuestionType().isGrid()) + .filter(savedInfo -> savedInfo.getQuestionId() != null && requestedByOrder.get(savedInfo.getQuestionOrder()) != null) + .forEach(savedInfo -> { + QuestionUpsertDto.UpsertInfo requestInfo = requestedByOrder.get(savedInfo.getQuestionOrder()); + + if (savedInfo.getQuestionType().isChoice()) { + choiceOptionUpsertDtos.add(OptionUpsertDto.builder() + .questionId(savedInfo.getQuestionId()) + .optionInfoList(requestInfo.getOptions() != null ? requestInfo.getOptions() : List.of()) + .build() + ); + } else if (savedInfo.getQuestionType().isGrid()) { + gridOptionUpsertDtos.add(GridOptionUpsertDto.builder() + .questionId(savedInfo.getQuestionId()) + .gridOptionInfoList(requestInfo.getGridOptions() != null ? requestInfo.getGridOptions() : List.of()) + .build() + ); + } + }); + + return new OptionUpsertBundle(choiceOptionUpsertDtos, gridOptionUpsertDtos); } /** questionId 기준 OptionUpsertDto 맵핑 */ private Map mapOptionsByQuestionId(List optionUpsertDtoList) { return optionUpsertDtoList.stream() - .collect(Collectors.toMap( - OptionUpsertDto::getQuestionId, - Function.identity() - )); + .collect(Collectors.toMap( + OptionUpsertDto::getQuestionId, + Function.identity() + )); + } + + /** questionId 기준 GridOptionUpsertDto 맵핑 */ + private Map mapGridOptionsByQuestionId(List gridOptionUpsertDtoList) { + return gridOptionUpsertDtoList.stream() + .collect(Collectors.toMap( + GridOptionUpsertDto::getQuestionId, + Function.identity() + )); } /** UPSERT된 보기 정보를 questionUpsertDto에 다시 반영 */ private void applyOptionsToQuestionUpsertDto( QuestionUpsertDto questionUpsertDto, - Map optionDtoMap + Map optionDtoMap, + Map gridOptionDtoMap ) { - questionUpsertDto.getUpsertInfoList().forEach(upsertInfo -> { - Long questionId = upsertInfo.getQuestionId(); - OptionUpsertDto optionInfoList = optionDtoMap.get(questionId); - - if (optionInfoList != null) { - upsertInfo.setOptions(optionInfoList.getOptionInfoList()); - } + questionUpsertDto.getUpsertInfoList().stream() + .filter(upsertInfo -> upsertInfo.getQuestionType().isChoice() || upsertInfo.getQuestionType().isGrid()) + .forEach(upsertInfo -> { + Long questionId = upsertInfo.getQuestionId(); + + if (upsertInfo.getQuestionType().isChoice()) { + OptionUpsertDto optionInfoList = optionDtoMap.get(questionId); + if (optionInfoList != null) { + upsertInfo.updateOptions(optionInfoList.getOptionInfoList()); + } + } else { + GridOptionUpsertDto gridOptionUpsertDto = gridOptionDtoMap.get(questionId); + if (gridOptionUpsertDto != null) { + upsertInfo.updateGridOptions(gridOptionUpsertDto.getGridOptionInfoList()); + } + } }); } + + private record OptionUpsertBundle( + List choiceOptionUpsertDtos, + List gridOptionUpsertDtos + ) { } } \ No newline at end of file diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java index 70a1a834..b663c4ce 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java @@ -1,12 +1,16 @@ package OneQ.OnSurvey.domain.survey.service.formRequest; import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.QuestionUpsertDto; import OneQ.OnSurvey.domain.question.model.dto.type.ChoiceDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; +import OneQ.OnSurvey.domain.question.model.dto.type.GridDto; import OneQ.OnSurvey.domain.question.model.dto.type.RatingDto; +import OneQ.OnSurvey.domain.question.model.dto.type.TimeDto; import OneQ.OnSurvey.domain.question.service.QuestionCommand; import OneQ.OnSurvey.domain.survey.model.formRequest.ConversionDto; import OneQ.OnSurvey.domain.survey.model.formRequest.FormValidationPostResponse; @@ -42,7 +46,7 @@ public Long createSurveyFromConversionResult(ConversionDto dto, Long memberId) { } // 3. 문항 생성 - List upsertInfoList = dto.questions().stream() + Map upsertInfoList = dto.questions().stream() .map(q -> { QuestionType type = QuestionType.valueOf(q.getQuestionType()); QuestionUpsertDto.UpsertInfo.UpsertInfoBuilder builder = QuestionUpsertDto.UpsertInfo.builder() @@ -74,48 +78,74 @@ public Long createSurveyFromConversionResult(ConversionDto dto, Long memberId) { .rate(ratingDto.getRate()) .build(); } + case GRID -> { + GridDto gridDto = (GridDto) q; + yield builder + .isCheckbox(gridDto.getIsCheckBox()) + .isChoiceMixed(gridDto.getIsChoiceMixed()) + .isChoiceDistinct(gridDto.getIsChoiceDistinct()) + .gridOptions(gridDto.getGridOptions()) + .build(); + } + case TIME -> { + TimeDto timeDto = (TimeDto) q; + yield builder.isInterval(timeDto.getIsInterval()).build(); + } default -> builder.build(); }; }) - .toList(); + .collect(Collectors.toMap(QuestionUpsertDto.UpsertInfo::getQuestionOrder, Function.identity())); if (!upsertInfoList.isEmpty()) { QuestionUpsertDto upsertDto = QuestionUpsertDto.builder() .surveyId(surveyId) - .upsertInfoList(upsertInfoList) + .upsertInfoList(upsertInfoList.values().stream().toList()) .build(); // 4. 전체 문항 저장 QuestionUpsertDto savedQuestions = questionCommand.upsertQuestionList(upsertDto); - // 5. CHOICE 문항 옵션 저장 + // 5. CHOICE, GRID 문항 옵션 저장 List optionUpsertDtoList = new ArrayList<>(); + List gridOptionUpsertDtoList = new ArrayList<>(); List savedInfoList = savedQuestions.getUpsertInfoList(); - Map originalInfoMap = upsertInfoList.stream() - .collect(java.util.stream.Collectors.toMap(QuestionUpsertDto.UpsertInfo::getQuestionOrder, Function.identity())); - - for (QuestionUpsertDto.UpsertInfo savedInfo : savedInfoList) { - QuestionUpsertDto.UpsertInfo originalInfo = originalInfoMap.get(savedInfo.getQuestionOrder()); - - if (savedInfo.getQuestionType().isChoice() && originalInfo.getOptions() != null) { - List options = originalInfo.getOptions().stream() - .map(opt -> OptionDto.builder() - .questionId(savedInfo.getQuestionId()) - .content(opt.getContent()) - .nextSection(opt.getNextSection()) - .imageUrl(opt.getImageUrl()) - .build()) - .toList(); - - optionUpsertDtoList.add(OptionUpsertDto.builder() - .questionId(savedInfo.getQuestionId()) - .optionInfoList(options) - .build()); - } - } - + savedInfoList.stream() + .filter(info -> info.getQuestionType().isChoice() || info.getQuestionType().isGrid()) + .forEach(info -> { + QuestionUpsertDto.UpsertInfo originalInfo = upsertInfoList.get(info.getQuestionOrder()); + if (originalInfo.getQuestionType().isChoice() && originalInfo.getOptions() != null) { + optionUpsertDtoList.add(OptionUpsertDto.builder() + .questionId(info.getQuestionId()) + .optionInfoList(originalInfo.getOptions().stream() + .map(opt -> OptionDto.builder() + .questionId(info.getQuestionId()) + .content(opt.getContent()) + .nextSection(opt.getNextSection()) + .imageUrl(opt.getImageUrl()) + .build() + ) + .toList()) + .build()); + } else if (originalInfo.getQuestionType().isGrid() && originalInfo.getGridOptions() != null) { + gridOptionUpsertDtoList.add(GridOptionUpsertDto.builder() + .questionId(info.getQuestionId()) + .gridOptionInfoList(originalInfo.getGridOptions().stream() + .map(opt -> GridOptionDto.builder() + .questionId(info.getQuestionId()) + .isRow(opt.getIsRow()) + .content(opt.getContent()) + .order(opt.getOrder()) + .build() + ) + .toList()) + .build()); + } + }); if (!optionUpsertDtoList.isEmpty()) { questionCommand.upsertChoiceOptionList(optionUpsertDtoList); } + if (!gridOptionUpsertDtoList.isEmpty()) { + questionCommand.upsertGridOptionList(gridOptionUpsertDtoList); + } } return surveyId; } From 1ef21af5efd8f3efb0d19541a8631aea182ebb12 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 24 Apr 2026 09:40:34 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=A7=91=EA=B3=84/=EC=A0=9C=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20=EA=B7=B8=EB=A6=AC=EB=93=9C,=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=ED=83=80=EC=9E=85=20=EB=AC=B8=ED=95=AD=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../participation/entity/QuestionAnswer.java | 7 +- .../model/dto/AnswerInsertDto.java | 5 +- .../participation/model/dto/AnswerStats.java | 15 ++- .../repository/answer/AnswerRepository.java | 3 + .../answer/QuestionAnswerRepositoryImpl.java | 24 ++++- .../answer/QuestionAnswerCommandService.java | 89 ++++++----------- .../answer/QuestionAnswerQueryService.java | 42 +++++++- .../question/QuestionRepository.java | 1 + .../question/QuestionRepositoryImpl.java | 13 +++ .../controller/ManagementController.java | 97 ++++++++++++++----- .../controller/ParticipationController.java | 7 +- .../request/InsertQuestionAnswerRequest.java | 30 ++++++ .../SurveyManagementDetailResponse.java | 24 ++++- 13 files changed, 257 insertions(+), 100 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java b/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java index edb20337..c6dc938f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java @@ -18,13 +18,17 @@ public class QuestionAnswer extends AbstractAnswer { @Column(name = "question_id") private Long questionId; + @Column(name = "GRID_ROW_ORDER") + private Integer gridRowOrder; + @Column(length = 512) private String content; @Builder - private QuestionAnswer(Long questionId, Long memberId, String content) { + private QuestionAnswer(Long questionId, Long memberId, Integer gridRowOrder, String content) { this.questionId = questionId; this.memberId = memberId; + this.gridRowOrder = gridRowOrder; this.content = content; } @@ -32,6 +36,7 @@ public static QuestionAnswer from(AnswerInsertDto.AnswerInfo info) { return QuestionAnswer.builder() .questionId(info.getId()) .memberId(info.getMemberId()) + .gridRowOrder(info.getGridRowOrder()) .content(info.getContent()) .build(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerInsertDto.java b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerInsertDto.java index 71380cea..9eef7fe9 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerInsertDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerInsertDto.java @@ -7,12 +7,15 @@ @Getter @Builder public class AnswerInsertDto { - List answerInfoList; + private Integer section; + private List answerInfoList; @Getter @Builder public static class AnswerInfo { private Long id; private Long memberId; + private Integer gridRowOrder; + // TODO: 응답값을 List로 받도록 하여, 중복선택 응답 처리를 쉽도록 수정 (스크리닝 응답은 단일 원소의 리스트로 받도록) private String content; public Boolean getBooleanContent() { diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerStats.java b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerStats.java index 98e4c1e4..9c480d81 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerStats.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/model/dto/AnswerStats.java @@ -5,12 +5,21 @@ @Getter @AllArgsConstructor public class AnswerStats { - Long questionId; - String content; - Long count; + private Long questionId; + private Integer gridRowOrder; + private String content; + private Long count; + + public AnswerStats(Long questionId, String content, Long count) { + this.questionId = questionId; + this.gridRowOrder = null; + this.content = content; + this.count = count; + } public AnswerStats(Long questionId, String content) { this.questionId = questionId; + this.gridRowOrder = null; this.content = content; this.count = 1L; } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java index 4fca5f44..b76be921 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java @@ -15,6 +15,9 @@ default List getAnswerListByQuestionIdsAndMemberId(Collection questionI return List.of(); } + default void deleteBySurveyIdAndSectionAndMemberId(Long surveyId, Integer section, Long memberId) { + } + List getAggregatedAnswersByQuestionIds(List questionIdList); default List getAnswersByQuestionIds(List questionIdList){ return List.of(); diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index e5bf6e43..31810f65 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -11,6 +11,7 @@ import com.querydsl.core.types.dsl.EnumPath; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import org.springframework.stereotype.Repository; @@ -44,10 +45,28 @@ public List getAnswerListByQuestionIdsAndMemberId(Collection getAggregatedAnswersByQuestionIds(List questionIdList) { return jpaQueryFactory.select(Projections.constructor(AnswerStats.class, questionAnswer.questionId, + questionAnswer.gridRowOrder, questionAnswer.content, questionAnswer.answerId.count() )) @@ -59,7 +78,7 @@ public List getAggregatedAnswersByQuestionIds(List questionId questionAnswer.questionId.in(questionIdList), response.isResponded.isTrue() ) - .groupBy(questionAnswer.questionId, questionAnswer.content) + .groupBy(questionAnswer.questionId, questionAnswer.gridRowOrder, questionAnswer.content) .orderBy(questionAnswer.questionId.asc()) .fetch(); } @@ -94,6 +113,7 @@ public List getAggregatedAnswersByQuestionIds( .select(Projections.constructor( AnswerStats.class, questionAnswer.questionId, + questionAnswer.gridRowOrder, questionAnswer.content, questionAnswer.answerId.count() )) @@ -109,7 +129,7 @@ public List getAggregatedAnswersByQuestionIds( buildGenderCondition(member.gender, effective.genders()), buildResidenceCondition(member.residence, effective.residences()) ) - .groupBy(questionAnswer.questionId, questionAnswer.content) + .groupBy(questionAnswer.questionId, questionAnswer.gridRowOrder, questionAnswer.content) .orderBy(questionAnswer.questionId.asc()) .fetch(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java index 03aab5f7..e159555e 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerCommandService.java @@ -5,6 +5,7 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; import OneQ.OnSurvey.domain.participation.repository.answer.AnswerRepository; import OneQ.OnSurvey.domain.participation.repository.response.ResponseRepository; +import OneQ.OnSurvey.domain.question.repository.question.QuestionRepository; import OneQ.OnSurvey.domain.survey.SurveyErrorCode; import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.exception.ErrorCode; @@ -15,13 +16,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collection; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; @Slf4j @Service @@ -31,14 +28,17 @@ public class QuestionAnswerCommandService extends AnswerCommandService answerRepository, ResponseRepository responseRepository, - RedisAgent redisAgent + RedisAgent redisAgent, + QuestionRepository questionRepository ) { super(answerRepository, responseRepository); this.redisAgent = redisAgent; + this.questionRepository = questionRepository; } @Override @@ -50,70 +50,37 @@ public QuestionAnswer createAnswerFromDto(AnswerInsertDto.AnswerInfo answerInfo) @Override public Boolean upsertAnswers(AnswerInsertDto insertDto, Long surveyId, Long userKey, Long memberId) { log.info("[QUESTION_ANSWER:COMMAND] 문항 응답 생성 - memberId: {}", memberId); - - // 새로운 응답을 questionId 기준으로 그룹화 - Map> newQuestionAnswerMap = insertDto.getAnswerInfoList().stream() - .map(this::createAnswerFromDto) - .collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet())); - List questionIdList = newQuestionAnswerMap.keySet().stream().toList(); - - String lockKey = surveyLockKeyPrefix + surveyId + ":" + userKey; try { - return redisAgent.executeNewTransactionAfterLock(lockKey, 3, () -> { - /* - 새로운 응답의 questionId로부터 기존 응답 조회 및 그룹화 - Phantom Read 방지를 위해 조회 로직도 락 내부에서 실행 - */ - Map> existingQuestionAnswerMap = - answerRepository.getAnswerListByQuestionIdsAndMemberId(questionIdList, memberId) - .stream() - .collect(Collectors.groupingBy(QuestionAnswer::getQuestionId, Collectors.toSet())); - - // 새로 저장할 응답 리스트 - List finalAnswersToSave = new ArrayList<>(); - // 삭제하지 않을 ID - Set idSetToKeep = new HashSet<>(); + return redisAgent.executeNewTransactionAfterLock(lockKey, 0, () -> { + int section = insertDto.getSection(); + Set questionIdSet = new HashSet<>(questionRepository.getQuestionIdListBySurveyIdAndSection(surveyId, section)); + + // 섹션에 해당하는 문항에 대한 응답 제출이 이루어졌는지 검증 + List answerInfoList = insertDto.getAnswerInfoList() != null ? insertDto.getAnswerInfoList() : List.of(); + boolean hasInvalidQuestionId = answerInfoList.stream() + .map(AnswerInsertDto.AnswerInfo::getId) + .anyMatch(questionId -> questionId == null || !questionIdSet.contains(questionId)); + if (hasInvalidQuestionId) { + log.warn("[QUESTION_ANSWER:COMMAND] 섹션, 문항 ID 불일치 - section: {}", section); + throw new CustomException(SurveyErrorCode.SURVEY_ANSWER_INVALID); + } - questionIdList.forEach(questionId -> { - // questionId에 대한 새로운 응답과 기존 응답의 content 집합 생성 - Set newAnswerContentSet = newQuestionAnswerMap.getOrDefault(questionId, Set.of()); - Set newContents = newAnswerContentSet.stream() - .map(QuestionAnswer::getContent) - .map(content -> content == null ? null : content.strip()) - .collect(Collectors.toSet()); - Set existingAnswerContentSet = existingQuestionAnswerMap.getOrDefault(questionId, Set.of()); - Set existingContents = existingAnswerContentSet.stream() - .map(QuestionAnswer::getContent) - .collect(Collectors.toSet()); + // 문항이 없는 빈 섹션 + if (questionIdSet.isEmpty()) { + return true; + } - // 새로운 응답이 null을 포함한 경우(객관식) 혹은 빈 문자열인 경우(단답/장문), 해당 문항의 기존 응답은 모두 삭제 대상에 남겨둠 - if (!newContents.contains(null) && !newContents.contains("")) { - // 새로운 응답 중 기존에 없는 content는 저장 대상에 추가 - newAnswerContentSet.stream() - .filter(newAnswer -> !existingContents.contains(newAnswer.getContent())) - .forEach(finalAnswersToSave::add); - // 새로운 응답에 포함된 기존 응답은 삭제 대상에서 제외 - existingAnswerContentSet.stream() - .filter(existingAnswer -> newContents.contains(existingAnswer.getContent())) - .map(QuestionAnswer::getAnswerId) - .forEach(idSetToKeep::add); - } - }); - // 삭제할 기존 응답 ID 리스트 (초기값: 기존 응답 전체) - List finalAnswerIdsToDelete = existingQuestionAnswerMap.values().stream() - .flatMap(Collection::stream) - .map(QuestionAnswer::getAnswerId) - .collect(Collectors.toList()); - finalAnswerIdsToDelete.removeAll(idSetToKeep); + List finalAnswersToSave = answerInfoList.stream() + .filter(info -> info.getContent() != null && !info.getContent().isBlank()) + .map(this::createAnswerFromDto) + .peek(answer -> answer.updateContent(answer.getContent().strip())) + .toList(); + answerRepository.deleteBySurveyIdAndSectionAndMemberId(surveyId, section, memberId); if (!finalAnswersToSave.isEmpty()) { answerRepository.saveAll(finalAnswersToSave); } - if (!finalAnswerIdsToDelete.isEmpty()) { - answerRepository.deleteAllByIds(finalAnswerIdsToDelete); - } - updateResponseAfterQuestionAnswers(surveyId, memberId); return true; diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java index c13ddc8a..134e328c 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java @@ -40,14 +40,13 @@ public List getDetailInfo( ) { log.info("[QUESTION_ANSWER_SERVICE] 문항 별 응답결과 조회 - surveyId: {}, filter: {}", surveyId, filter); - Map> typeInfoMap = detailInfoList.stream() - .collect(Collectors.partitioningBy(detailInfo -> detailInfo.getType().isText())); - - List nonTextQuestionIdList = typeInfoMap.get(false).stream() + List textQuestionIdList = detailInfoList.stream() + .filter(detailInfo -> detailInfo.getType().isText()) .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) .toList(); - List textQuestionIdList = typeInfoMap.get(true).stream() + List nonTextQuestionIdList = detailInfoList.stream() + .filter(detailInfo -> !detailInfo.getType().isText()) .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) .toList(); @@ -75,6 +74,7 @@ public List getDetailInfo( : answerRepository.getAnswersByQuestionIds(textQuestionIdList, filter); Map> nonTextAnswerMap = nonTextAnswerStats.stream() + .filter(stats -> stats.getGridRowOrder() == null) .collect(Collectors.groupingBy( AnswerStats::getQuestionId, Collectors.toMap( @@ -83,6 +83,19 @@ public List getDetailInfo( ) )); + Map>> gridAnswerMap = nonTextAnswerStats.stream() + .filter(stats -> stats.getGridRowOrder() != null) + .collect(Collectors.groupingBy( + AnswerStats::getQuestionId, + Collectors.groupingBy( + AnswerStats::getGridRowOrder, + Collectors.toMap( + AnswerStats::getContent, + AnswerStats::getCount + ) + ) + )); + Map> textAnswerMap = textAnswerStats.stream() .collect(Collectors.groupingBy( AnswerStats::getQuestionId, @@ -98,6 +111,25 @@ public List getDetailInfo( textAnswerMap.getOrDefault(questionId, List.of()) ); + } else if (questionType.isGrid()) { + Map> frame = detailInfo.getGridAnswerMap(); + Map> rowOrderAnswerMap = gridAnswerMap.getOrDefault(questionId, Map.of()); + + List rowKeyList = new ArrayList<>(frame.keySet()); + rowOrderAnswerMap.forEach((rowOrder, columnAnswerMap) -> { + String rowKey = rowOrder < rowKeyList.size() ? rowKeyList.get(rowOrder) : null; + if (rowKey == null || frame.get(rowKey) == null) { + return; + } + + Map rowFrame = frame.get(rowKey); + rowFrame.keySet().forEach(col -> + rowFrame.put(col, columnAnswerMap.getOrDefault(col, 0L)) + ); + }); + + detailInfo.setGridAnswerMap(frame); + } else if (questionType.isChoice()) { Map frame = detailInfo.getAnswerMap(); Map answerMap = nonTextAnswerMap.getOrDefault(questionId, Map.of()); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java index d929a878..8694d7f3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepository.java @@ -16,4 +16,5 @@ public interface QuestionRepository { void deleteAll(Set idList); void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection order); int countBySurveyId(Long surveyId); + List getQuestionIdListBySurveyIdAndSection(Long surveyId, Integer section); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java index ca7acc94..aec6717f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java @@ -60,4 +60,17 @@ public void deleteBySurveyIdAndNotInOrder(Long surveyId, Collection ord public int countBySurveyId(Long surveyId) { return questionJpaRepository.countBySurveyId(surveyId); } + + @Override + public List getQuestionIdListBySurveyIdAndSection(Long surveyId, Integer section) { + return jpaQueryFactory + .select(question.questionId) + .from(question) + .where( + question.surveyId.eq(surveyId), + question.section.eq(section) + ) + .orderBy(question.section.asc()) + .fetch(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java index 0077f203..5f85d165 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java @@ -4,6 +4,7 @@ import OneQ.OnSurvey.domain.participation.service.answer.AnswerQuery; import OneQ.OnSurvey.domain.participation.service.response.ResponseQuery; import OneQ.OnSurvey.domain.question.model.QuestionType; +import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; import OneQ.OnSurvey.domain.question.model.dto.SectionDto; import OneQ.OnSurvey.domain.question.model.dto.type.DefaultQuestionDto; @@ -98,29 +99,79 @@ public SuccessResponse getSurveyManagementDetail )) .toList(); - List choiceIdList = detailInfoList.stream() - .filter(dto -> dto.getType().isChoice()) - .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) - .toList(); - - if (!choiceIdList.isEmpty()) { - List optionInfoList = questionQuery.getOptionsByQuestionIdList(choiceIdList); - Map> questionIdOptionInfoMap = optionInfoList.stream() - .collect(Collectors.groupingBy(OptionDto::getQuestionId)); - - detailInfoList.forEach(detailInfo -> { - List optionDtoList = questionIdOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); - - Map contentMap = optionDtoList.stream() - .sorted(Comparator.comparingLong(OptionDto::getOptionId)) - .collect(Collectors.toMap( - OptionDto::getContent, - dto -> 0L, - (existing, replacement) -> existing, - LinkedHashMap::new - )); - detailInfo.setAnswerMap(contentMap.isEmpty() ? Map.of() : contentMap); - }); + Map> typeIdMap = detailInfoList.stream() + .filter(info -> info.getType().isChoice() || info.getType().isGrid()) + .collect(Collectors.groupingBy( + SurveyManagementDetailResponse.DetailInfo::getType, + Collectors.mapping(SurveyManagementDetailResponse.DetailInfo::getQuestionId, Collectors.toList()) + )); + + if (!typeIdMap.isEmpty()) { + List choiceIdList = typeIdMap.getOrDefault(QuestionType.CHOICE, List.of()); + if (!choiceIdList.isEmpty()) { + List optionInfoList = questionQuery.getOptionsByQuestionIdList(choiceIdList); + Map> questionIdOptionInfoMap = optionInfoList.stream() + .collect(Collectors.groupingBy(OptionDto::getQuestionId)); + + detailInfoList.stream() + .filter(detailInfo -> detailInfo.getType().isChoice()) + .forEach(detailInfo -> { + List optionDtoList = questionIdOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); + + Map contentMap = optionDtoList.stream() + .sorted(Comparator.comparingLong(OptionDto::getOptionId)) + .collect(Collectors.toMap( + OptionDto::getContent, + dto -> 0L, + (existing, replacement) -> existing, + LinkedHashMap::new + )); + detailInfo.setAnswerMap(contentMap.isEmpty() ? Map.of() : contentMap); + }); + } + + List gridIdList = typeIdMap.getOrDefault(QuestionType.GRID, List.of()); + if (!gridIdList.isEmpty()) { + List gridOptionInfoList = questionQuery.getGridOptionsByQuestionIdList(gridIdList); + Map> questionIdGridOptionInfoMap = gridOptionInfoList.stream() + .collect(Collectors.groupingBy(GridOptionDto::getQuestionId)); + detailInfoList.stream() + .filter(detailInfo -> detailInfo.getType().isGrid()) + .forEach(detailInfo -> { + List optionDtoList = questionIdGridOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); + + Comparator comparator = Comparator.comparing( + GridOptionDto::getOrder, + Comparator.nullsLast(Comparator.naturalOrder()) + ); + List rowList = optionDtoList.stream() + .filter(dto -> Boolean.TRUE.equals(dto.getIsRow())) + .sorted(comparator) + .map(GridOptionDto::getContent) + .toList(); + + List columnList = optionDtoList.stream() + .filter(dto -> Boolean.FALSE.equals(dto.getIsRow())) + .sorted(comparator) + .map(GridOptionDto::getContent) + .toList(); + + Map> gridMap = rowList.stream() + .collect(Collectors.toMap( + row -> row, + row -> columnList.stream().collect(Collectors.toMap( + column -> column, + column -> 0L, + (existing, replacement) -> existing, + LinkedHashMap::new + )), + (existing, replacement) -> existing, + LinkedHashMap::new + )); + + detailInfo.setGridAnswerMap(gridMap.isEmpty() ? Map.of() : gridMap); + }); + } } detailInfoList = answerQuery.getDetailInfo(surveyId, filter, detailInfoList); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index 23327f22..75d2b54b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -21,6 +21,7 @@ import OneQ.OnSurvey.global.common.exception.CustomException; import OneQ.OnSurvey.global.common.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; @@ -248,13 +249,13 @@ public SuccessResponse createScreeningAnswer( public SuccessResponse createQuestionAnswer( @AuthenticationPrincipal CustomUserDetails principal, @PathVariable Long surveyId, - @RequestBody InsertQuestionAnswerRequest request + @RequestBody @Valid InsertQuestionAnswerRequest request ) { log.info("[PARTICIPATION] 설문 응답 생성 - surveyId: {}, userKey: {}, request: {}", surveyId, principal.getMemberId(), request.toString()); - if (request.isEmpty()) { - log.warn("[PARTICIPATION] 빈 응답 생성 요청 - surveyId: {}, userKey: {}", surveyId, principal.getMemberId()); + if (request.getSection() == null || request.getInfoList() == null) { + log.warn("[PARTICIPATION] 유효하지 않은 응답 생성 요청 - surveyId: {}, userKey: {}", surveyId, principal.getMemberId()); throw new CustomException(SurveyErrorCode.SURVEY_ANSWER_INVALID); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java index 46c132f8..36930e6e 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java @@ -2,6 +2,10 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerInsertDto; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,20 +14,46 @@ @Getter @Builder public class InsertQuestionAnswerRequest { + @NotNull @Positive + private Integer section; + + @Schema( + description = "문항 응답 목록", + example = """ + [ + { "questionId": 100, "rowOrder": null, "content": "옵션 A" }, + { "questionId": 101, "rowOrder": 0, "content": "매우 만족" }, + { "questionId": 101, "rowOrder": 0, "content": "매우 불만족" }, + { "questionId": 101, "rowOrder": 1, "content": "보통" } + ] + """ + ) private List infoList; @Getter @AllArgsConstructor + @Schema(description = "개별 문항 응답 정보") public static class QuestionAnswerInfo { + @Schema(description = "문항 ID", example = "202") + @NotNull private Long questionId; + + @Schema(description = "그리드 행 순서(0부터 시작). GRID 문항이 아니면 null", example = "0", nullable = true) + @PositiveOrZero + private Integer rowOrder; + + @Schema(description = "응답 내용(선택 값/텍스트/그리드 열 값)", example = "매우 만족") private String content; } public AnswerInsertDto toDto(Long memberId) { + infoList = (infoList != null) ? infoList : List.of(); return AnswerInsertDto.builder() + .section(section) .answerInfoList(infoList.stream().map(info -> AnswerInsertDto.AnswerInfo.builder() .id(info.getQuestionId()) .memberId(memberId) + .gridRowOrder(info.getRowOrder()) .content(info.getContent()) .build()) .toList()) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyManagementDetailResponse.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyManagementDetailResponse.java index f7e6cbfd..32c12a07 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyManagementDetailResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/response/SurveyManagementDetailResponse.java @@ -68,8 +68,30 @@ public static class DetailInfo { ] """ ) - // 주관식 (SHORT, LONG, DATE, NUMBER) 설문 필드 + // 주관식 (SHORT, LONG, DATE, NUMBER, TIME) 설문 필드 private List answerList; + + @Setter + @Schema( + description = "(그리드) 응답값", + example = """ + { + "row1": { + "col1": 5, + "col2": 15, + "col3": 10 + }, + "row2": { + "col1": 5, + "col2": 15, + "col3": 10, + "col4": 10 + } + } + """ + ) + // 그리드 (GRID) 설문 필드, [ 행: (열: 응답 수) ] + private Map> gridAnswerMap; } public void updateCurrentCount(Integer currentCount) { From ff1f4cf305e442c293640b3351bdb29dd111de4f Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 24 Apr 2026 10:16:06 +0900 Subject: [PATCH 07/32] =?UTF-8?q?chore:=20=EB=A1=9C=EA=B9=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../answer/ScreeningAnswerQueryService.java | 2 - .../service/QuestionCommandService.java | 445 +++++++++--------- .../controller/FormRequestController.java | 1 + .../controller/ParticipationController.java | 2 +- 4 files changed, 217 insertions(+), 233 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/ScreeningAnswerQueryService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/ScreeningAnswerQueryService.java index 4dacaa0c..ef554efe 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/ScreeningAnswerQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/ScreeningAnswerQueryService.java @@ -32,8 +32,6 @@ public List getDetailInfo( .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) .toList(); - log.info("[SCREENING_ANSWER_SERVICE] 응답을 조회할 스크리닝 IDs - screeningIds: {}", screeningIdList); - List screeningAnswerStats = answerRepository.getAggregatedAnswersByQuestionIds(screeningIdList); Map> screeningAnswerMap = screeningAnswerStats.stream() diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 8a473f54..8f835069 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -60,9 +60,6 @@ public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { .map(QuestionUpsertDto.UpsertInfo::getQuestionId) .collect(Collectors.toSet()); - log.info("[QUESTION:COMMAND:upsertQuestionList] 수정되는 문항 IDs: {}", updateIdSet); - log.info("[QUESTION:COMMAND:upsertQuestionList] 생성되는 문항 개수: {}", newInfoList.size()); - // 4. Delete 대상 ID 추출 및 삭제 if (newInfoList.isEmpty()) { Set deleteIdSet = prevQuestionList.stream() @@ -71,8 +68,6 @@ public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { .collect(Collectors.toSet()); if (!deleteIdSet.isEmpty()) { - log.info("[QUESTION:COMMAND:upsertQuestionList] 삭제되는 문항 IDs: {}", deleteIdSet); - // 문항 삭제 전에 해당 문항의 보기(ChoiceOption)도 삭제 List optionsToDelete = choiceOptionRepository.getOptionsByQuestionIds(deleteIdSet); List gridOptionsToDelete = gridOptionRepository.getGridOptionsByQuestionIds(deleteIdSet); @@ -86,12 +81,10 @@ public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { Set gridOptionIdsToDelete = gridOptionsToDelete.stream() .map(GridOption::getGridOptionId) .collect(Collectors.toSet()); - log.info("[QUESTION:COMMAND:upsertQuestionList] 삭제되는 보기 IDs: {}", gridOptionIdsToDelete); gridOptionRepository.deleteAll(gridOptionIdsToDelete); } questionRepository.deleteAll(deleteIdSet); - log.info("[QUESTION:COMMAND:upsertQuestionList] DELETE 진행"); } } @@ -136,6 +129,221 @@ public QuestionUpsertDto upsertQuestionList(QuestionUpsertDto upsertDto) { .build(); } + @Override + public List upsertChoiceOptionList(List upsertDtoList) { + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT"); + + List finalList = new ArrayList<>(); + + for (OptionUpsertDto upsertDto : upsertDtoList) { + Long questionId = upsertDto.getQuestionId(); + List requestInfos = upsertDto.getOptionInfoList(); + + // 1. DB 저장 보기 전체 조회 + List prevOptionList = choiceOptionRepository.getOptionsByQuestionId(questionId); + + // 2. Insert/Update 데이터 파티셔닝 + Map> partitionUpsertInfoList = requestInfos.stream() + .collect(Collectors.partitioningBy(info -> info.getOptionId() != null)); + + List newInfoList = partitionUpsertInfoList.get(false); + List updateInfoList = partitionUpsertInfoList.get(true); + + // 3. Update 대상 ID 추출 + Set updateIdSet = updateInfoList.stream() + .map(OptionDto::getOptionId) + .collect(Collectors.toSet()); + + // 4. Delete 대상 ID 추출 및 삭제 + Set deleteIdSet = prevOptionList.stream() + .map(ChoiceOption::getChoiceOptionId) + .filter(optionId -> !updateIdSet.contains(optionId)) + .collect(Collectors.toSet()); + if (!deleteIdSet.isEmpty()) { + choiceOptionRepository.deleteAll(deleteIdSet); + } + + // 5. Update 대상 수정 + Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( + OptionDto::getOptionId, + Function.identity(), + (existing, replace) -> existing + )); + List updateList = prevOptionList.stream() + .filter(option -> updateIdSet.contains(option.getChoiceOptionId())) + .toList(); + + updateList.forEach(option -> { + Long id = option.getChoiceOptionId(); + OptionDto optionInfo = updateInfoMap.get(id); + option.updateOption( + optionInfo.getContent(), + optionInfo.getNextSection(), + optionInfo.getImageUrl() + ); + }); + + // 6. Insert 대상 객체 생성 + List insertList = newInfoList.stream() + .map(upsertInfo -> ChoiceOption.of( + questionId, + upsertInfo.getContent(), + upsertInfo.getNextSection(), + upsertInfo.getImageUrl() + )).toList(); + + finalList.addAll(updateList); + finalList.addAll(insertList); + } + + // 7. Update/Insert 진행 + List optionList = choiceOptionRepository.saveAll(finalList); + + log.info("[QUESTION:COMMAND:upsertChoiceOptionList] UPSERT 완료"); + + // 8. 반환값 구성 + Map> idOptionListMap = optionList.stream().collect(Collectors.groupingBy(ChoiceOption::getQuestionId)); + return idOptionListMap.entrySet().stream().map(entry -> { + Long questionId = entry.getKey(); + List savedList = entry.getValue(); + + return OptionUpsertDto.builder() + .questionId(questionId) + .optionInfoList(savedList.stream().map(OptionDto::fromEntity).toList()) + .build(); + }) + .toList(); + } + + @Override + public List upsertGridOptionList(List upsertDtoList) { + log.info("[QUESTION:COMMAND:upsertGridOptionList] 그리드 문항 행/열 UPSERT"); + + List finalList = new ArrayList<>(); + for (GridOptionUpsertDto upsertDto : upsertDtoList) { + Long questionId = upsertDto.getQuestionId(); + List requestInfos = upsertDto.getGridOptionInfoList(); + + // 1. DB 저장 보기 전체 조회 + List prevOptionList = gridOptionRepository.getGridOptionsByQuestionId(questionId); + + // 2. Insert/Update 데이터 파티셔닝 + Map> partitionUpsertInfoList = requestInfos.stream() + .collect(Collectors.partitioningBy(info -> info.getGridOptionId() != null)); + + List newInfoList = partitionUpsertInfoList.get(false); + List updateInfoList = partitionUpsertInfoList.get(true); + + // 3. Update 대상 ID 추출 + Set updateIdSet = updateInfoList.stream() + .map(GridOptionDto::getGridOptionId) + .collect(Collectors.toSet()); + + // 4. Delete 대상 ID 추출 및 삭제 + Set deleteIdSet = prevOptionList.stream() + .map(GridOption::getGridOptionId) + .filter(optionId -> !updateIdSet.contains(optionId)) + .collect(Collectors.toSet()); + if (!deleteIdSet.isEmpty()) { + gridOptionRepository.deleteAll(deleteIdSet); + } + + // 5. Update 대상 수정 + Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( + GridOptionDto::getGridOptionId, + Function.identity(), + (existing, replace) -> existing + )); + List updateList = prevOptionList.stream() + .filter(option -> updateIdSet.contains(option.getGridOptionId())) + .toList(); + + updateList.forEach(option -> { + Long id = option.getGridOptionId(); + GridOptionDto optionInfo = updateInfoMap.get(id); + option.updateGridOption( + optionInfo.getContent(), + optionInfo.getOrder() + ); + }); + + // 6. Insert 대상 객체 생성 + List insertList = newInfoList.stream() + .map(upsertInfo -> GridOption.of( + questionId, + upsertInfo.getIsRow(), + upsertInfo.getContent(), + upsertInfo.getOrder() + )).toList(); + + finalList.addAll(updateList); + finalList.addAll(insertList); + } + + // 7. Update/Insert 진행 + List optionList = gridOptionRepository.saveAll(finalList); + log.info("[QUESTION:COMMAND:upsertGridOptionList] UPSERT 완료"); + + // 8. 반환값 구성 + Map> idOptionListMap = optionList.stream().collect(Collectors.groupingBy(GridOption::getQuestionId)); + return idOptionListMap.entrySet().stream().map(entry -> { + Long questionId = entry.getKey(); + List savedList = entry.getValue(); + + return GridOptionUpsertDto.builder() + .questionId(questionId) + .gridOptionInfoList(savedList.stream().map(GridOptionDto::fromEntity).toList()) + .build(); + }) + .toList(); + } + + @Override + public List upsertSections(Long surveyId, List sectionDtoList) { + if (!sectionDtoList.stream().allMatch(SectionDto::isValid)) { + log.warn("[QUESTION:COMMAND:upsertSection] 섹션 정보가 유효하지 않습니다. surveyId: {}", surveyId); + throw new CustomException(SurveyErrorCode.SURVEY_FORM_INVALID_SECTION); + } + + Map orderSectionMap = sectionRepository.findAllSectionBySurveyId(surveyId).stream() + .collect(Collectors.toMap + (Section::getSectionOrder, Function.identity()) + ); + List
sectionList = sectionDtoList.stream().map(sectionDto -> { + if (sectionDto.isNewSection()) { + return Section.builder() + .surveyId(surveyId) + .title(sectionDto.title()) + .description(sectionDto.description()) + .sectionOrder(sectionDto.order()) + .nextSection(sectionDto.nextSection()) + .build(); + } else { + Section section = orderSectionMap.get(sectionDto.order()); + section.updateSection( + sectionDto.title(), sectionDto.description(), sectionDto.order(), sectionDto.nextSection() + ); + orderSectionMap.remove(sectionDto.order()); + return section; + } + }).toList(); + List
savedSectionList = List.of(); + if (!orderSectionMap.isEmpty()) { + sectionRepository.deleteAll(orderSectionMap.values().stream().map(Section::getSectionId).toList()); + } + if (!sectionDtoList.isEmpty()) { + savedSectionList = sectionRepository.saveAll(sectionList); + } + if (!savedSectionList.isEmpty()) { + Set savedSections = savedSectionList.stream().map(Section::getSectionOrder).collect(Collectors.toSet()); + choiceOptionRepository.deleteBySections(surveyId, savedSections); + gridOptionRepository.deleteBySections(surveyId, savedSections); + questionRepository.deleteBySurveyIdAndNotInOrder(surveyId, savedSections); + } + + return savedSectionList.stream().map(SectionDto::fromEntity).toList(); + } + private void updateQuestion(QuestionUpsertDto.UpsertInfo upsertInfo, Question question) { if (question instanceof Choice choice) { choice.updateQuestion( @@ -385,227 +593,4 @@ private Question createQuestion(Long surveyId, QuestionUpsertDto.UpsertInfo upse throw new CustomException(ErrorCode.INVALID_REQUEST); } } - - @Override - public List upsertChoiceOptionList(List upsertDtoList) { - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 보기 UPSERT"); - - List finalList = new ArrayList<>(); - - for (OptionUpsertDto upsertDto : upsertDtoList) { - Long questionId = upsertDto.getQuestionId(); - List requestInfos = upsertDto.getOptionInfoList(); - - // 1. DB 저장 보기 전체 조회 - List prevOptionList = choiceOptionRepository.getOptionsByQuestionId(questionId); - - // 2. Insert/Update 데이터 파티셔닝 - Map> partitionUpsertInfoList = requestInfos.stream() - .collect(Collectors.partitioningBy(info -> info.getOptionId() != null)); - - List newInfoList = partitionUpsertInfoList.get(false); - List updateInfoList = partitionUpsertInfoList.get(true); - - // 3. Update 대상 ID 추출 - Set updateIdSet = updateInfoList.stream() - .map(OptionDto::getOptionId) - .collect(Collectors.toSet()); - - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 수정되는 문항: {}, 보기 IDs: {}", questionId, updateIdSet); - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 생성되는 문항: {}, 보기 개수: {}", questionId, newInfoList.size()); - - // 4. Delete 대상 ID 추출 및 삭제 - Set deleteIdSet = prevOptionList.stream() - .map(ChoiceOption::getChoiceOptionId) - .filter(optionId -> !updateIdSet.contains(optionId)) - .collect(Collectors.toSet()); - if (!deleteIdSet.isEmpty()) { - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); - choiceOptionRepository.deleteAll(deleteIdSet); - } - - // 5. Update 대상 수정 - Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( - OptionDto::getOptionId, - Function.identity(), - (existing, replace) -> existing - )); - List updateList = prevOptionList.stream() - .filter(option -> updateIdSet.contains(option.getChoiceOptionId())) - .toList(); - - updateList.forEach(option -> { - Long id = option.getChoiceOptionId(); - OptionDto optionInfo = updateInfoMap.get(id); - option.updateOption( - optionInfo.getContent(), - optionInfo.getNextSection(), - optionInfo.getImageUrl() - ); - }); - - // 6. Insert 대상 객체 생성 - List insertList = newInfoList.stream() - .map(upsertInfo -> ChoiceOption.of( - questionId, - upsertInfo.getContent(), - upsertInfo.getNextSection(), - upsertInfo.getImageUrl() - )).toList(); - - finalList.addAll(updateList); - finalList.addAll(insertList); - } - - // 7. Update/Insert 진행 - List optionList = choiceOptionRepository.saveAll(finalList); - - log.info("[QUESTION:COMMAND:upsertChoiceOptionList] UPSERT 완료"); - - // 8. 반환값 구성 - Map> idOptionListMap = optionList.stream().collect(Collectors.groupingBy(ChoiceOption::getQuestionId)); - return idOptionListMap.entrySet().stream().map(entry -> { - Long questionId = entry.getKey(); - List savedList = entry.getValue(); - - return OptionUpsertDto.builder() - .questionId(questionId) - .optionInfoList(savedList.stream().map(OptionDto::fromEntity).toList()) - .build(); - }) - .toList(); - } - - @Override - public List upsertGridOptionList(List upsertDtoList) { - log.info("[QUESTION:COMMAND:upsertGridOptionList] 그리드 문항 행/열 UPSERT"); - - List finalList = new ArrayList<>(); - for (GridOptionUpsertDto upsertDto : upsertDtoList) { - Long questionId = upsertDto.getQuestionId(); - List requestInfos = upsertDto.getGridOptionInfoList(); - - // 1. DB 저장 보기 전체 조회 - List prevOptionList = gridOptionRepository.getGridOptionsByQuestionId(questionId); - - // 2. Insert/Update 데이터 파티셔닝 - Map> partitionUpsertInfoList = requestInfos.stream() - .collect(Collectors.partitioningBy(info -> info.getGridOptionId() != null)); - - List newInfoList = partitionUpsertInfoList.get(false); - List updateInfoList = partitionUpsertInfoList.get(true); - - // 3. Update 대상 ID 추출 - Set updateIdSet = updateInfoList.stream() - .map(GridOptionDto::getGridOptionId) - .collect(Collectors.toSet()); - - log.info("[QUESTION:COMMAND:upsertGridOptionList] 수정되는 문항: {}, 보기 IDs: {}", questionId, updateIdSet); - log.info("[QUESTION:COMMAND:upsertGridOptionList] 생성되는 문항: {}, 보기 개수: {}", questionId, newInfoList.size()); - - // 4. Delete 대상 ID 추출 및 삭제 - Set deleteIdSet = prevOptionList.stream() - .map(GridOption::getGridOptionId) - .filter(optionId -> !updateIdSet.contains(optionId)) - .collect(Collectors.toSet()); - if (!deleteIdSet.isEmpty()) { - log.info("[QUESTION:COMMAND:upsertGridOptionList] 삭제되는 문항: {}, 보기 IDs: {}", questionId, deleteIdSet); - gridOptionRepository.deleteAll(deleteIdSet); - } - - // 5. Update 대상 수정 - Map updateInfoMap = updateInfoList.stream().collect(Collectors.toMap( - GridOptionDto::getGridOptionId, - Function.identity(), - (existing, replace) -> existing - )); - List updateList = prevOptionList.stream() - .filter(option -> updateIdSet.contains(option.getGridOptionId())) - .toList(); - - updateList.forEach(option -> { - Long id = option.getGridOptionId(); - GridOptionDto optionInfo = updateInfoMap.get(id); - option.updateGridOption( - optionInfo.getContent(), - optionInfo.getOrder() - ); - }); - - // 6. Insert 대상 객체 생성 - List insertList = newInfoList.stream() - .map(upsertInfo -> GridOption.of( - questionId, - upsertInfo.getIsRow(), - upsertInfo.getContent(), - upsertInfo.getOrder() - )).toList(); - - finalList.addAll(updateList); - finalList.addAll(insertList); - } - - // 7. Update/Insert 진행 - List optionList = gridOptionRepository.saveAll(finalList); - log.info("[QUESTION:COMMAND:upsertGridOptionList] UPSERT 완료"); - - // 8. 반환값 구성 - Map> idOptionListMap = optionList.stream().collect(Collectors.groupingBy(GridOption::getQuestionId)); - return idOptionListMap.entrySet().stream().map(entry -> { - Long questionId = entry.getKey(); - List savedList = entry.getValue(); - - return GridOptionUpsertDto.builder() - .questionId(questionId) - .gridOptionInfoList(savedList.stream().map(GridOptionDto::fromEntity).toList()) - .build(); - }) - .toList(); - } - - @Override - public List upsertSections(Long surveyId, List sectionDtoList) { - if (!sectionDtoList.stream().allMatch(SectionDto::isValid)) { - log.warn("[QUESTION:COMMAND:upsertSection] 섹션 정보가 유효하지 않습니다. surveyId: {}", surveyId); - throw new CustomException(SurveyErrorCode.SURVEY_FORM_INVALID_SECTION); - } - - Map orderSectionMap = sectionRepository.findAllSectionBySurveyId(surveyId).stream() - .collect(Collectors.toMap - (Section::getSectionOrder, Function.identity()) - ); - List
sectionList = sectionDtoList.stream().map(sectionDto -> { - if (sectionDto.isNewSection()) { - return Section.builder() - .surveyId(surveyId) - .title(sectionDto.title()) - .description(sectionDto.description()) - .sectionOrder(sectionDto.order()) - .nextSection(sectionDto.nextSection()) - .build(); - } else { - Section section = orderSectionMap.get(sectionDto.order()); - section.updateSection( - sectionDto.title(), sectionDto.description(), sectionDto.order(), sectionDto.nextSection() - ); - orderSectionMap.remove(sectionDto.order()); - return section; - } - }).toList(); - List
savedSectionList = List.of(); - if (!orderSectionMap.isEmpty()) { - sectionRepository.deleteAll(orderSectionMap.values().stream().map(Section::getSectionId).toList()); - } - if (!sectionDtoList.isEmpty()) { - savedSectionList = sectionRepository.saveAll(sectionList); - } - if (!savedSectionList.isEmpty()) { - Set savedSections = savedSectionList.stream().map(Section::getSectionOrder).collect(Collectors.toSet()); - choiceOptionRepository.deleteBySections(surveyId, savedSections); - gridOptionRepository.deleteBySections(surveyId, savedSections); - questionRepository.deleteBySurveyIdAndNotInOrder(surveyId, savedSections); - } - - return savedSectionList.stream().map(SectionDto::fromEntity).toList(); - } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java index 30aed89a..cf5434d2 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/FormRequestController.java @@ -43,6 +43,7 @@ public SuccessResponse createGoogleFormRequest( @AuthenticationPrincipal Authenticatable principal, @RequestBody @Valid FormRequestDto request ) { + log.info("[FormRequest] 폼 등록 요청 - userKey: {}", principal.getUserKey()); return SuccessResponse.ok(formCreator.createFormRequest(principal.getUserKey(), principal.getMemberId(), request)); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index 75d2b54b..e5c537cd 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -189,7 +189,7 @@ public SuccessResponse getTotalSurveyInfoOfSurveyId( Survey survey = surveyQueryService.getSurveyById(surveyId); if (surveyQueryService.checkValidSegmentation(surveyId, principal.getUserKey())) { - log.info("[PARTICIPATION] 세그먼트 불일치로 인한 설문 응답 불가 - surveyId: {}, userKey: {}", surveyId, principal.getUserKey()); + log.warn("[PARTICIPATION] 세그먼트 불일치로 인한 설문 응답 불가 - surveyId: {}, userKey: {}", surveyId, principal.getUserKey()); throw new CustomException(SurveyErrorCode.SURVEY_WRONG_SEGMENTATION); } From 6fbf500aa4f5188d54781184918814a7c868cba8 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Fri, 24 Apr 2026 18:48:40 +0900 Subject: [PATCH 08/32] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/infra/mapper/AdminSurveyMapper.java | 2 +- .../participation/entity/QuestionAnswer.java | 6 +- .../repository/answer/AnswerRepository.java | 3 + .../answer/QuestionAnswerQueryService.java | 87 ++++++------ .../domain/question/entity/GridOption.java | 4 +- .../domain/question/entity/Question.java | 10 +- .../domain/question/entity/question/Grid.java | 6 +- .../model/dto/type/DefaultQuestionDto.java | 4 +- .../question/model/dto/type/GridDto.java | 6 +- .../question/QuestionRepositoryImpl.java | 2 +- .../service/QuestionCommandService.java | 1 + .../question/service/QuestionConverter.java | 2 +- .../service/QuestionQueryService.java | 2 +- .../controller/ManagementController.java | 124 +++++++++--------- .../controller/ParticipationController.java | 5 - .../request/InsertQuestionAnswerRequest.java | 1 + .../survey/service/form/SurveyFormFacade.java | 2 +- .../service/formRequest/FormConverter.java | 3 +- 18 files changed, 131 insertions(+), 139 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java index 84deff99..b10b87de 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/infra/mapper/AdminSurveyMapper.java @@ -122,7 +122,7 @@ public static SurveyQuestion toSurveyQuestion(DefaultQuestionDto questionDto) { GridDto grid = (GridDto) questionDto; yield question.gridProperty( new SurveyQuestion.GridProp( - grid.getIsCheckBox(), + grid.getIsCheckbox(), grid.getIsChoiceMixed(), grid.getIsChoiceDistinct(), grid.getGridOptions().stream() diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java b/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java index c6dc938f..774608ad 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/entity/QuestionAnswer.java @@ -12,16 +12,16 @@ @Getter @ToString @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity @Table(name = "question_answer") +@Entity @Table(name = "QUESTION_ANSWER") public class QuestionAnswer extends AbstractAnswer { - @Column(name = "question_id") + @Column(name = "QUESTION_ID", nullable = false) private Long questionId; @Column(name = "GRID_ROW_ORDER") private Integer gridRowOrder; - @Column(length = 512) + @Column(length = 512, nullable = false) private String content; @Builder diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java index b76be921..31a23602 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/AnswerRepository.java @@ -2,6 +2,8 @@ import OneQ.OnSurvey.domain.participation.model.dto.AnswerStats; import OneQ.OnSurvey.domain.survey.model.SurveyResponseFilterCondition; +import OneQ.OnSurvey.global.common.exception.CustomException; +import OneQ.OnSurvey.global.common.exception.ErrorCode; import java.util.Collection; import java.util.List; @@ -16,6 +18,7 @@ default List getAnswerListByQuestionIdsAndMemberId(Collection questionI } default void deleteBySurveyIdAndSectionAndMemberId(Long surveyId, Integer section, Long memberId) { + throw new CustomException(ErrorCode.SERVER_UNTRACKED_ERROR); } List getAggregatedAnswersByQuestionIds(List questionIdList); diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java index 134e328c..92bf0f26 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/service/answer/QuestionAnswerQueryService.java @@ -41,66 +41,67 @@ public List getDetailInfo( log.info("[QUESTION_ANSWER_SERVICE] 문항 별 응답결과 조회 - surveyId: {}, filter: {}", surveyId, filter); List textQuestionIdList = detailInfoList.stream() - .filter(detailInfo -> detailInfo.getType().isText()) - .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) - .toList(); + .filter(detailInfo -> detailInfo.getType().isText()) + .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) + .toList(); List nonTextQuestionIdList = detailInfoList.stream() - .filter(detailInfo -> !detailInfo.getType().isText()) - .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) - .toList(); + .filter(detailInfo -> !detailInfo.getType().isText()) + .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) + .toList(); log.info("[QUESTION_ANSWER_SERVICE] 응답을 조회할 문항 IDs - 주관식: {}, 비주관식: {}", - textQuestionIdList, nonTextQuestionIdList); + textQuestionIdList, nonTextQuestionIdList); List allQuestionIdList = detailInfoList.stream() - .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) - .toList(); + .map(SurveyManagementDetailResponse.DetailInfo::getQuestionId) + .toList(); List respondentCountStats = answerRepository.getRespondentCountsByQuestionIds(allQuestionIdList, filter); Map respondentCountMap = respondentCountStats.stream() - .collect(Collectors.toMap(AnswerStats::getQuestionId, AnswerStats::getCount)); + .collect(Collectors.toMap(AnswerStats::getQuestionId, AnswerStats::getCount)); detailInfoList.forEach(detailInfo -> - detailInfo.setRespondentCount(respondentCountMap.getOrDefault(detailInfo.getQuestionId(), 0L)) + detailInfo.setRespondentCount(respondentCountMap.getOrDefault(detailInfo.getQuestionId(), 0L)) ); List nonTextAnswerStats = nonTextQuestionIdList.isEmpty() - ? List.of() - : answerRepository.getAggregatedAnswersByQuestionIds(nonTextQuestionIdList, filter); + ? List.of() + : answerRepository.getAggregatedAnswersByQuestionIds(nonTextQuestionIdList, filter); List textAnswerStats = textQuestionIdList.isEmpty() - ? List.of() - : answerRepository.getAnswersByQuestionIds(textQuestionIdList, filter); + ? List.of() + : answerRepository.getAnswersByQuestionIds(textQuestionIdList, filter); Map> nonTextAnswerMap = nonTextAnswerStats.stream() - .filter(stats -> stats.getGridRowOrder() == null) - .collect(Collectors.groupingBy( - AnswerStats::getQuestionId, - Collectors.toMap( - AnswerStats::getContent, - AnswerStats::getCount - ) - )); + .filter(stats -> stats.getGridRowOrder() == null) + .collect(Collectors.groupingBy( + AnswerStats::getQuestionId, + Collectors.toMap( + AnswerStats::getContent, + AnswerStats::getCount + ) + )); Map>> gridAnswerMap = nonTextAnswerStats.stream() - .filter(stats -> stats.getGridRowOrder() != null) - .collect(Collectors.groupingBy( - AnswerStats::getQuestionId, - Collectors.groupingBy( - AnswerStats::getGridRowOrder, - Collectors.toMap( - AnswerStats::getContent, - AnswerStats::getCount - ) - ) - )); + .filter(stats -> stats.getGridRowOrder() != null) + .collect(Collectors.groupingBy( + AnswerStats::getQuestionId, + Collectors.groupingBy( + AnswerStats::getGridRowOrder, + Collectors.toMap( + AnswerStats::getContent, + AnswerStats::getCount, + Long::sum + ) + ) + )); Map> textAnswerMap = textAnswerStats.stream() - .collect(Collectors.groupingBy( - AnswerStats::getQuestionId, - Collectors.mapping(AnswerStats::getContent, Collectors.toList()) - )); + .collect(Collectors.groupingBy( + AnswerStats::getQuestionId, + Collectors.mapping(AnswerStats::getContent, Collectors.toList()) + )); detailInfoList.forEach(detailInfo -> { Long questionId = detailInfo.getQuestionId(); @@ -108,7 +109,7 @@ public List getDetailInfo( if (questionType.isText()) { detailInfo.setAnswerList( - textAnswerMap.getOrDefault(questionId, List.of()) + textAnswerMap.getOrDefault(questionId, List.of()) ); } else if (questionType.isGrid()) { @@ -135,14 +136,14 @@ public List getDetailInfo( Map answerMap = nonTextAnswerMap.getOrDefault(questionId, Map.of()); frame.keySet().forEach(key -> - frame.put(key, answerMap.getOrDefault(key, 0L)) + frame.put(key, answerMap.getOrDefault(key, 0L)) ); List etcList = new ArrayList<>(); answerMap.entrySet().stream() - .filter(entry -> !frame.containsKey(entry.getKey())) - .forEach(entry -> IntStream.range(0, entry.getValue().intValue()) - .forEach((ignored) -> etcList.add(entry.getKey()))); + .filter(entry -> !frame.containsKey(entry.getKey())) + .forEach(entry -> IntStream.range(0, entry.getValue().intValue()) + .forEach((ignored) -> etcList.add(entry.getKey()))); detailInfo.setAnswerList(etcList); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java index 93ea4fe4..d6e45377 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/GridOption.java @@ -31,10 +31,10 @@ public class GridOption { @Builder.Default private Boolean isRow = false; - @Column + @Column(nullable = false) private String content; - @Column(name = "GRID_ORDER") + @Column(name = "GRID_ORDER", nullable = false) private Integer order; public static GridOption of(Long questionId, Boolean isRow, String content, Integer order) { diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java index 5939dc0d..6e6fcff5 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/Question.java @@ -47,7 +47,7 @@ public abstract class Question extends BaseEntity { @Column(name = "image_url", columnDefinition = "TEXT") protected String imageUrl; - public void updateQuestion( + protected void updateQuestion( String title, String description, Boolean isRequired, @@ -63,14 +63,6 @@ public void updateQuestion( this.imageUrl = imageUrl; } - public void updateOrder(Integer order) { - this.order = order; - } - - public boolean isChoice() { - return QuestionType.CHOICE.equals(QuestionType.valueOf(this.type)); - } - public QuestionType getQuestionType() { return QuestionType.valueOf(this.type); } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java index 9da2fade..82f20fd0 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/entity/question/Grid.java @@ -76,8 +76,8 @@ public void updateQuestion( Boolean isChoiceDistinct ) { super.updateQuestion(title, description, isRequired, order, section, imageUrl); - this.isCheckbox = isCheckbox; - this.isChoiceMixed = isChoiceMixed; - this.isChoiceDistinct = isChoiceDistinct; + this.isCheckbox = isCheckbox != null ? isCheckbox : false; + this.isChoiceMixed = isChoiceMixed != null ? isChoiceMixed : false; + this.isChoiceDistinct = isChoiceDistinct != null ? isChoiceDistinct : false; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java index b7610184..a942f5e1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java @@ -62,11 +62,11 @@ public static DefaultQuestionDto fromEntity(Question question) { @JsonIgnore public boolean isChoice() { - return QuestionType.CHOICE.equals(QuestionType.valueOf(this.questionType)); + return QuestionType.CHOICE.name().equals(this.questionType); } @JsonIgnore public boolean isGrid() { - return QuestionType.GRID.equals(QuestionType.valueOf(this.questionType)); + return QuestionType.GRID.name().equals(this.questionType); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java index f0bcbaa0..7251c906 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java @@ -13,7 +13,7 @@ @Getter @SuperBuilder @ToString(callSuper = true) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class GridDto extends DefaultQuestionDto { - private Boolean isCheckBox; + private Boolean isCheckbox; private Boolean isChoiceMixed; private Boolean isChoiceDistinct; @@ -21,7 +21,7 @@ public class GridDto extends DefaultQuestionDto { public static GridDto fromEntity(Grid grid) { return GridDto.builder() - .isCheckBox(grid.getIsCheckbox()) + .isCheckbox(grid.getIsCheckbox()) .isChoiceMixed(grid.getIsChoiceMixed()) .isChoiceDistinct(grid.getIsChoiceDistinct()) .questionId(grid.getQuestionId()) @@ -36,7 +36,7 @@ public static GridDto fromEntity(Grid grid) { .build(); } - public void updateOptions(List gridOptions) { + public void updateGridOptions(List gridOptions) { this.gridOptions = gridOptions; } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java index aec6717f..8fa0e570 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/repository/question/QuestionRepositoryImpl.java @@ -70,7 +70,7 @@ public List getQuestionIdListBySurveyIdAndSection(Long surveyId, Integer s question.surveyId.eq(surveyId), question.section.eq(section) ) - .orderBy(question.section.asc()) + .orderBy(question.order.asc()) .fetch(); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java index 8f835069..bfb74478 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionCommandService.java @@ -262,6 +262,7 @@ public List upsertGridOptionList(List Long id = option.getGridOptionId(); GridOptionDto optionInfo = updateInfoMap.get(id); option.updateGridOption( + optionInfo.getContent(), optionInfo.getOrder() ); diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java index 94bc03e3..e421c3e3 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionConverter.java @@ -66,7 +66,7 @@ private static QuestionUpsertDto.UpsertInfo toUpsertInfo(DefaultQuestionDto dto) .maxValue(ratingDto.getMaxValue()) .rate(ratingDto.getRate()); case DateDto dateDto -> builder.defaultDate(dateDto.getDate()); - case GridDto gridDto -> builder.isCheckbox(gridDto.getIsCheckBox()) + case GridDto gridDto -> builder.isCheckbox(gridDto.getIsCheckbox()) .isChoiceMixed(gridDto.getIsChoiceMixed() != null ? gridDto.getIsChoiceMixed() : false) .isChoiceDistinct(gridDto.getIsChoiceDistinct() != null ? gridDto.getIsChoiceDistinct() : false) .gridOptions(gridDto.getGridOptions().stream().map(option -> diff --git a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java index 13914fb3..31f41647 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/service/QuestionQueryService.java @@ -96,7 +96,7 @@ private List fillOptions(List questionList) { } else if (dto.isGrid()) { GridDto gridDto = (GridDto) dto; List gridOptionList = gridIdOptionMap.getOrDefault(dto.getQuestionId(), List.of()); - gridDto.updateOptions(gridOptionList.stream().map(GridOptionDto::fromEntity).toList()); + gridDto.updateGridOptions(gridOptionList.stream().map(GridOptionDto::fromEntity).toList()); } }) .toList(); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java index 5f85d165..42749f32 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ManagementController.java @@ -106,72 +106,70 @@ public SuccessResponse getSurveyManagementDetail Collectors.mapping(SurveyManagementDetailResponse.DetailInfo::getQuestionId, Collectors.toList()) )); - if (!typeIdMap.isEmpty()) { - List choiceIdList = typeIdMap.getOrDefault(QuestionType.CHOICE, List.of()); - if (!choiceIdList.isEmpty()) { - List optionInfoList = questionQuery.getOptionsByQuestionIdList(choiceIdList); - Map> questionIdOptionInfoMap = optionInfoList.stream() - .collect(Collectors.groupingBy(OptionDto::getQuestionId)); - - detailInfoList.stream() - .filter(detailInfo -> detailInfo.getType().isChoice()) - .forEach(detailInfo -> { - List optionDtoList = questionIdOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); - - Map contentMap = optionDtoList.stream() - .sorted(Comparator.comparingLong(OptionDto::getOptionId)) - .collect(Collectors.toMap( - OptionDto::getContent, - dto -> 0L, - (existing, replacement) -> existing, - LinkedHashMap::new - )); - detailInfo.setAnswerMap(contentMap.isEmpty() ? Map.of() : contentMap); - }); - } - - List gridIdList = typeIdMap.getOrDefault(QuestionType.GRID, List.of()); - if (!gridIdList.isEmpty()) { - List gridOptionInfoList = questionQuery.getGridOptionsByQuestionIdList(gridIdList); - Map> questionIdGridOptionInfoMap = gridOptionInfoList.stream() - .collect(Collectors.groupingBy(GridOptionDto::getQuestionId)); - detailInfoList.stream() - .filter(detailInfo -> detailInfo.getType().isGrid()) - .forEach(detailInfo -> { - List optionDtoList = questionIdGridOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); - - Comparator comparator = Comparator.comparing( - GridOptionDto::getOrder, - Comparator.nullsLast(Comparator.naturalOrder()) - ); - List rowList = optionDtoList.stream() - .filter(dto -> Boolean.TRUE.equals(dto.getIsRow())) - .sorted(comparator) - .map(GridOptionDto::getContent) - .toList(); - - List columnList = optionDtoList.stream() - .filter(dto -> Boolean.FALSE.equals(dto.getIsRow())) - .sorted(comparator) - .map(GridOptionDto::getContent) - .toList(); - - Map> gridMap = rowList.stream() - .collect(Collectors.toMap( - row -> row, - row -> columnList.stream().collect(Collectors.toMap( - column -> column, - column -> 0L, - (existing, replacement) -> existing, - LinkedHashMap::new - )), + List choiceIdList = typeIdMap.getOrDefault(QuestionType.CHOICE, List.of()); + if (!choiceIdList.isEmpty()) { + List optionInfoList = questionQuery.getOptionsByQuestionIdList(choiceIdList); + Map> questionIdOptionInfoMap = optionInfoList.stream() + .collect(Collectors.groupingBy(OptionDto::getQuestionId)); + + detailInfoList.stream() + .filter(detailInfo -> detailInfo.getType().isChoice()) + .forEach(detailInfo -> { + List optionDtoList = questionIdOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); + + Map contentMap = optionDtoList.stream() + .sorted(Comparator.comparingLong(OptionDto::getOptionId)) + .collect(Collectors.toMap( + OptionDto::getContent, + dto -> 0L, + (existing, replacement) -> existing, + LinkedHashMap::new + )); + detailInfo.setAnswerMap(contentMap.isEmpty() ? Map.of() : contentMap); + }); + } + + List gridIdList = typeIdMap.getOrDefault(QuestionType.GRID, List.of()); + if (!gridIdList.isEmpty()) { + List gridOptionInfoList = questionQuery.getGridOptionsByQuestionIdList(gridIdList); + Map> questionIdGridOptionInfoMap = gridOptionInfoList.stream() + .collect(Collectors.groupingBy(GridOptionDto::getQuestionId)); + detailInfoList.stream() + .filter(detailInfo -> detailInfo.getType().isGrid()) + .forEach(detailInfo -> { + List optionDtoList = questionIdGridOptionInfoMap.getOrDefault(detailInfo.getQuestionId(), List.of()); + + Comparator comparator = Comparator.comparing( + GridOptionDto::getOrder, + Comparator.nullsLast(Comparator.naturalOrder()) + ); + List rowList = optionDtoList.stream() + .filter(dto -> Boolean.TRUE.equals(dto.getIsRow())) + .sorted(comparator) + .map(GridOptionDto::getContent) + .toList(); + + List columnList = optionDtoList.stream() + .filter(dto -> Boolean.FALSE.equals(dto.getIsRow())) + .sorted(comparator) + .map(GridOptionDto::getContent) + .toList(); + + Map> gridMap = rowList.stream() + .collect(Collectors.toMap( + row -> row, + row -> columnList.stream().collect(Collectors.toMap( + column -> column, + column -> 0L, (existing, replacement) -> existing, LinkedHashMap::new - )); + )), + (existing, replacement) -> existing, + LinkedHashMap::new + )); - detailInfo.setGridAnswerMap(gridMap.isEmpty() ? Map.of() : gridMap); - }); - } + detailInfo.setGridAnswerMap(gridMap.isEmpty() ? Map.of() : gridMap); + }); } detailInfoList = answerQuery.getDetailInfo(surveyId, filter, detailInfoList); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java index e5c537cd..67a8e458 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/controller/ParticipationController.java @@ -254,11 +254,6 @@ public SuccessResponse createQuestionAnswer( log.info("[PARTICIPATION] 설문 응답 생성 - surveyId: {}, userKey: {}, request: {}", surveyId, principal.getMemberId(), request.toString()); - if (request.getSection() == null || request.getInfoList() == null) { - log.warn("[PARTICIPATION] 유효하지 않은 응답 생성 요청 - surveyId: {}, userKey: {}", surveyId, principal.getMemberId()); - throw new CustomException(SurveyErrorCode.SURVEY_ANSWER_INVALID); - } - AnswerInsertDto answerInsertDto = request.toDto(principal.getMemberId()); return SuccessResponse.ok(questionAnswerCommand.upsertAnswers(answerInsertDto, surveyId, principal.getUserKey(), principal.getMemberId())); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java index 36930e6e..0b05c686 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/request/InsertQuestionAnswerRequest.java @@ -28,6 +28,7 @@ public class InsertQuestionAnswerRequest { ] """ ) + @NotNull private List infoList; @Getter @AllArgsConstructor diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java index f5bafc73..4f6afc3b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/form/SurveyFormFacade.java @@ -256,7 +256,7 @@ private void applyOptionsToQuestionUpsertDto( if (optionInfoList != null) { upsertInfo.updateOptions(optionInfoList.getOptionInfoList()); } - } else { + } else if (upsertInfo.getQuestionType().isGrid()){ GridOptionUpsertDto gridOptionUpsertDto = gridOptionDtoMap.get(questionId); if (gridOptionUpsertDto != null) { upsertInfo.updateGridOptions(gridOptionUpsertDto.getGridOptionInfoList()); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java index b663c4ce..0e74543b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormConverter.java @@ -81,7 +81,7 @@ public Long createSurveyFromConversionResult(ConversionDto dto, Long memberId) { case GRID -> { GridDto gridDto = (GridDto) q; yield builder - .isCheckbox(gridDto.getIsCheckBox()) + .isCheckbox(gridDto.getIsCheckbox()) .isChoiceMixed(gridDto.getIsChoiceMixed()) .isChoiceDistinct(gridDto.getIsChoiceDistinct()) .gridOptions(gridDto.getGridOptions()) @@ -110,6 +110,7 @@ public Long createSurveyFromConversionResult(ConversionDto dto, Long memberId) { List savedInfoList = savedQuestions.getUpsertInfoList(); savedInfoList.stream() .filter(info -> info.getQuestionType().isChoice() || info.getQuestionType().isGrid()) + .filter(info -> upsertInfoList.get(info.getQuestionOrder()) != null) .forEach(info -> { QuestionUpsertDto.UpsertInfo originalInfo = upsertInfoList.get(info.getQuestionOrder()); if (originalInfo.getQuestionType().isChoice() && originalInfo.getOptions() != null) { From 90a9207188afebbc63d23130ca7c76af36bfac9d Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 25 Apr 2026 17:09:12 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat:=20=EC=84=A4=EB=AC=B8=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20CSV=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=AC=B8=ED=95=AD=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/export/SurveyAnswerProjection.java | 2 ++ .../model/export/SurveyQuestionHeader.java | 2 ++ .../export/SurveyExportRepositoryImpl.java | 19 ++++++++++++++----- .../service/export/SurveyExportService.java | 16 +++++++++++----- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyAnswerProjection.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyAnswerProjection.java index 4ac97aff..e2afea24 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyAnswerProjection.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyAnswerProjection.java @@ -9,4 +9,6 @@ public class SurveyAnswerProjection { private final Long memberId; private final Long questionId; private final String content; + // 그리드 이외 타입은 기본값 0을 가지도록 함. + private final Integer gridRowOrder; } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyQuestionHeader.java b/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyQuestionHeader.java index 802e4041..7b913eea 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyQuestionHeader.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/model/export/SurveyQuestionHeader.java @@ -9,4 +9,6 @@ public class SurveyQuestionHeader { private final Long questionId; private final Integer orderNo; private final String title; + private final Integer rowOrder; + private final String rowContent; } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java index 120520ba..0c90d189 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java @@ -13,6 +13,7 @@ import static OneQ.OnSurvey.domain.member.QMember.member; import static OneQ.OnSurvey.domain.participation.entity.QQuestionAnswer.questionAnswer; import static OneQ.OnSurvey.domain.participation.entity.QResponse.response; +import static OneQ.OnSurvey.domain.question.entity.QGridOption.gridOption; import static OneQ.OnSurvey.domain.question.entity.QQuestion.question; import static OneQ.OnSurvey.domain.survey.entity.QSurvey.survey; @@ -29,11 +30,17 @@ public List findQuestionHeaders(Long surveyId) { SurveyQuestionHeader.class, question.questionId, question.order, - question.title + question.title, + gridOption.order.coalesce(0), + gridOption.content )) .from(question) + .leftJoin(gridOption).on( + question.questionId.eq(gridOption.questionId), + gridOption.isRow.isTrue() + ) .where(question.surveyId.eq(surveyId)) - .orderBy(question.order.asc()) + .orderBy(question.order.asc(), gridOption.order.asc()) .fetch(); } @@ -51,7 +58,7 @@ public List findMembersWhoAnswered(Long surveyId) { .join(question).on(question.questionId.eq(questionAnswer.questionId)) .join(member).on(member.id.eq(questionAnswer.memberId)) .join(response).on(response.surveyId.eq(surveyId).and(response.memberId.eq(questionAnswer.memberId))) - .where(question.surveyId.eq(surveyId).and(response.isResponded.isTrue())) + .where(question.surveyId.eq(surveyId)) .distinct() .orderBy(member.id.asc()) .fetch(); @@ -64,12 +71,14 @@ public List findAnswers(Long surveyId) { SurveyAnswerProjection.class, questionAnswer.memberId, questionAnswer.questionId, - questionAnswer.content + questionAnswer.content, + questionAnswer.gridRowOrder.coalesce(0) )) .from(questionAnswer) .join(question).on(question.questionId.eq(questionAnswer.questionId)) .join(response).on(response.surveyId.eq(surveyId).and(response.memberId.eq(questionAnswer.memberId))) - .where(question.surveyId.eq(surveyId).and(response.isResponded.isTrue())) + .where(question.surveyId.eq(surveyId)) + .orderBy(response.updatedAt.asc(), question.order.asc(), questionAnswer.gridRowOrder.asc()) .fetch(); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java index 5ed4d0db..9286ff18 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java @@ -56,10 +56,11 @@ public SurveyExportFile exportCsv(Long surveyId, Long requesterMemberId) { log.info("[SurveyExport] fetched. surveyId={}, questions={}, members={}, answers={}", surveyId, headers.size(), members.size(), answers.size()); - Map>> answerMap = new HashMap<>(); + Map>>> answerMap = new HashMap<>(); for (SurveyAnswerProjection a : answers) { answerMap.computeIfAbsent(a.getMemberId(), k -> new HashMap<>()) - .computeIfAbsent(a.getQuestionId(), k -> new TreeSet<>()) + .computeIfAbsent(a.getQuestionId(), k -> new HashMap<>()) + .computeIfAbsent(a.getGridRowOrder() , k -> new TreeSet<>()) .add(a.getContent()); } @@ -71,7 +72,7 @@ public SurveyExportFile exportCsv(Long surveyId, Long requesterMemberId) { if (includeGender) headerCols.add("gender"); headerCols.add("residence"); for (SurveyQuestionHeader h : headers) { - headerCols.add("Q" + nvlInt(h.getOrderNo() + 1) + ". " + nvl(h.getTitle())); + headerCols.add("Q" + nvlInt(h.getOrderNo() + 1) + ". " + nvl(h.getTitle()) + gridNvl(h.getRowContent())); } sb.append(String.join(",", escapeCsv(headerCols))).append("\n"); @@ -90,9 +91,11 @@ public SurveyExportFile exportCsv(Long surveyId, Long requesterMemberId) { row.add(nvl(m.getResidence())); - Map> memberAnswers = answerMap.getOrDefault(m.getMemberId(), Map.of()); + Map>> memberAnswers = answerMap.getOrDefault(m.getMemberId(), Map.of()); for (SurveyQuestionHeader h : headers) { - row.add(nvl(String.join(",", memberAnswers.getOrDefault(h.getQuestionId(), Set.of())))); + row.add(nvl(String.join(",", memberAnswers.getOrDefault( + h.getQuestionId(), Map.of()).getOrDefault( + h.getRowOrder(), Set.of())))); } sb.append(String.join(",", escapeCsv(row))).append("\n"); @@ -139,6 +142,9 @@ private boolean shouldIncludeAge(SurveyInfo info) { private String nvl(String s) { return s == null ? "" : s; } private String nvlInt(Integer i) { return i == null ? "" : String.valueOf(i); } + private String gridNvl(String s) { + return s == null ? "" : " [" + s + "]"; + } private List escapeCsv(List vals) { List out = new ArrayList<>(vals.size()); From 4b27880f2b6e642c95f7c37b209362f845ffbf05 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 25 Apr 2026 17:18:25 +0900 Subject: [PATCH 10/32] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=88=84=EB=9D=BD=EB=90=9C=20where=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../survey/repository/export/SurveyExportRepositoryImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java index 0c90d189..bd2384cc 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/repository/export/SurveyExportRepositoryImpl.java @@ -58,7 +58,7 @@ public List findMembersWhoAnswered(Long surveyId) { .join(question).on(question.questionId.eq(questionAnswer.questionId)) .join(member).on(member.id.eq(questionAnswer.memberId)) .join(response).on(response.surveyId.eq(surveyId).and(response.memberId.eq(questionAnswer.memberId))) - .where(question.surveyId.eq(surveyId)) + .where(question.surveyId.eq(surveyId).and(response.isResponded.isTrue())) .distinct() .orderBy(member.id.asc()) .fetch(); @@ -77,7 +77,7 @@ public List findAnswers(Long surveyId) { .from(questionAnswer) .join(question).on(question.questionId.eq(questionAnswer.questionId)) .join(response).on(response.surveyId.eq(surveyId).and(response.memberId.eq(questionAnswer.memberId))) - .where(question.surveyId.eq(surveyId)) + .where(question.surveyId.eq(surveyId).and(response.isResponded.isTrue())) .orderBy(response.updatedAt.asc(), question.order.asc(), questionAnswer.gridRowOrder.asc()) .fetch(); } From c292d4505e9e8480d7f1fcefc17373e692be94cb Mon Sep 17 00:00:00 2001 From: shash0423 Date: Wed, 29 Apr 2026 15:34:14 +0900 Subject: [PATCH 11/32] =?UTF-8?q?hotfix:=20=EC=97=B0=EB=A0=B9=EB=8C=80=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(=EB=A7=8C=EB=82=98=EC=9D=B4,=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EB=82=98=EC=9D=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/answer/QuestionAnswerRepositoryImpl.java | 2 +- .../repository/response/ResponseRepositoryImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java index dcb2490b..7d0502f4 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/answer/QuestionAnswerRepositoryImpl.java @@ -275,7 +275,7 @@ private BooleanExpression ageRangeExpr(StringPath birthDayPath, AgeRange range) } return Expressions.booleanTemplate( - "(YEAR(CURDATE()) - CAST(SUBSTRING({0}, 1, 4) AS long)) BETWEEN {1} AND {2}", + "(YEAR(CURDATE()) - CAST(SUBSTRING({0}, 1, 4) AS long) + 1) BETWEEN {1} AND {2}", birthDayPath, minAge, maxAge ); } diff --git a/src/main/java/OneQ/OnSurvey/domain/participation/repository/response/ResponseRepositoryImpl.java b/src/main/java/OneQ/OnSurvey/domain/participation/repository/response/ResponseRepositoryImpl.java index 8012a0db..48885f8f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/participation/repository/response/ResponseRepositoryImpl.java +++ b/src/main/java/OneQ/OnSurvey/domain/participation/repository/response/ResponseRepositoryImpl.java @@ -140,7 +140,7 @@ private BooleanExpression ageRangeExpr(StringPath birthDayPath, AgeRange range) } return Expressions.booleanTemplate( - "(YEAR(CURDATE()) - CAST(SUBSTRING({0}, 1, 4) AS long)) BETWEEN {1} AND {2}", + "(YEAR(CURDATE()) - CAST(SUBSTRING({0}, 1, 4) AS long) + 1) BETWEEN {1} AND {2}", birthDayPath, minAge, maxAge ); } From 3d882eb9c94df52029039aa5200e191f9e412243 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Wed, 29 Apr 2026 17:17:29 +0900 Subject: [PATCH 12/32] =?UTF-8?q?hotfix:=20=EC=97=B0=EB=A0=B9=EB=8C=80=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C=EC=97=90=EB=8F=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../survey/service/export/SurveyExportService.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java index 5ed4d0db..970a970a 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java @@ -19,7 +19,6 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; -import java.time.Period; import java.util.*; import static OneQ.OnSurvey.domain.survey.SurveyErrorCode.SURVEY_FORBIDDEN; @@ -165,16 +164,10 @@ private Integer toAge(String birthDay) { if (digits.length() < 4) return null; int year; - int month = 1; - int day = 1; try { year = Integer.parseInt(digits.substring(0, 4)); - if (digits.length() >= 6) month = Integer.parseInt(digits.substring(4, 6)); - if (digits.length() >= 8) day = Integer.parseInt(digits.substring(6, 8)); - - LocalDate dob = LocalDate.of(year, month, day); - return Period.between(dob, LocalDate.now()).getYears(); + return LocalDate.now().getYear() - year + 1; } catch (Exception e) { return null; } From 3d661c1dcbb91f4f37884ece500d7e5024e641bb Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:19:10 +0900 Subject: [PATCH 13/32] feat(admin): add GET /v1/admin/surveys/{surveyId}/export endpoint --- .../domain/admin/api/AdminController.java | 24 +++++++++++++++++++ .../domain/admin/application/AdminFacade.java | 10 ++++++++ .../admin/domain/port/in/AdminUseCase.java | 3 +++ 3 files changed, 37 insertions(+) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java index 9cb29056..502d7b6c 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java @@ -9,15 +9,23 @@ import OneQ.OnSurvey.domain.admin.api.dto.response.SurveyGrantStatsResponse; import OneQ.OnSurvey.domain.admin.application.AdminFacade; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; import OneQ.OnSurvey.global.common.response.PageResponse; import OneQ.OnSurvey.global.common.response.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; import java.util.List; @Slf4j @@ -77,6 +85,22 @@ public SuccessResponse> getOngoingSurveys() { ); } + @GetMapping("/surveys/{surveyId}/export") + @Operation(summary = "설문 응답 CSV 다운로드 (어드민)", description = "어드민 권한으로 특정 설문의 응답 데이터를 CSV 파일로 다운로드합니다.") + public ResponseEntity exportSurveyCsv(@PathVariable Long surveyId) { + log.info("[ADMIN] 설문 CSV 다운로드 - surveyId: {}", surveyId); + SurveyExportFile file = adminFacade.exportSurveyCsv(surveyId); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment() + .filename(file.filename(), StandardCharsets.UTF_8) + .build() + .toString()) + .contentLength(file.bytes().length) + .body(new ByteArrayResource(file.bytes())); + } + @PatchMapping("/surveys/{surveyId}/owner") @Operation(summary = "설문 소유자 변경 (어드민)", description = "어드민 권한으로 설문의 소유자를 변경합니다.") public SuccessResponse changeSurveyOwner( diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java index 67b93251..dd93a86b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java @@ -18,6 +18,8 @@ import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; import OneQ.OnSurvey.domain.admin.domain.port.out.SurveyPort; import OneQ.OnSurvey.domain.admin.domain.repository.AdminRepository; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; +import OneQ.OnSurvey.domain.survey.service.export.SurveyExport; import OneQ.OnSurvey.global.promotion.port.out.PromotionGrantRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -38,6 +40,7 @@ public class AdminFacade implements AuthUseCase, AdminUseCase { private final SurveyPort surveyPort; private final PasswordEncoder passwordEncoder; private final PromotionGrantRepository promotionGrantRepository; + private final SurveyExport surveyExport; @Override public String authenticate(String username, String rawPassword) { @@ -116,4 +119,11 @@ public List getSurveyGrantStats() { public List getOngoingSurveys() { return surveyPort.findOngoingSurveys(); } + + @Override + public SurveyExportFile exportSurveyCsv(Long surveyId) { + // BOSessionFilter sets ROLE_ADMIN in SecurityContext, so the ownership check inside + // SurveyExportService is bypassed via AuthorizationUtils.isAdmin(). + return surveyExport.exportCsv(surveyId, 0L); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java index 04b5aa5d..e8f9ac06 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/port/in/AdminUseCase.java @@ -6,6 +6,7 @@ import OneQ.OnSurvey.domain.admin.api.dto.response.SurveyGrantStatsResponse; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -24,4 +25,6 @@ public interface AdminUseCase { List getSurveyGrantStats(); List getOngoingSurveys(); + + SurveyExportFile exportSurveyCsv(Long surveyId); } From c49691294edad2e4c6c32693ca4fd921bf9e7cd7 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:19:26 +0900 Subject: [PATCH 14/32] feat(backoffice): add CSV download button to survey list --- src/main/resources/templates/bo/survey.html | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/resources/templates/bo/survey.html b/src/main/resources/templates/bo/survey.html index 7a2ea1a0..72f83374 100644 --- a/src/main/resources/templates/bo/survey.html +++ b/src/main/resources/templates/bo/survey.html @@ -2255,15 +2255,24 @@

${survey.memberId || '-'} ${formatDate(survey.createdAt)} - ${survey.status === 'WRITING' - ? `` - : `` - } +
+ ${survey.status === 'WRITING' + ? `` + : `` + } + +
`).join(''); } + function downloadSurveyCsv(surveyId) { + window.location.href = `/v1/admin/surveys/${surveyId}/export`; + } + // 페이지네이션 UI 렌더링 function renderPagination() { const infoContainer = document.getElementById('pagination-info'); From 6bb4596d80f3a4ae20e4c2ea74bc37129875e678 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:19:34 +0900 Subject: [PATCH 15/32] test(admin): add unit tests for admin CSV export --- .../admin/api/AdminControllerExportTest.java | 46 +++++++++++++++++++ .../application/AdminFacadeExportTest.java | 44 ++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java create mode 100644 src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java diff --git a/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java b/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java new file mode 100644 index 00000000..c8673d54 --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java @@ -0,0 +1,46 @@ +package OneQ.OnSurvey.domain.admin.api; + +import OneQ.OnSurvey.domain.admin.application.AdminFacade; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class AdminControllerExportTest { + + @Mock AdminFacade adminFacade; + @InjectMocks AdminController adminController; + + MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminController).build(); + } + + @Test + void exportSurveyCsv_returns200WithAttachmentHeader() throws Exception { + byte[] csvBytes = "age,gender\n30,MALE\n".getBytes(); + SurveyExportFile file = new SurveyExportFile(csvBytes, "my_survey_20260504.csv", "text/csv; charset=UTF-8"); + when(adminFacade.exportSurveyCsv(7L)).thenReturn(file); + + mockMvc.perform(get("/v1/admin/surveys/7/export")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment"))) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("my_survey_20260504.csv"))) + .andExpect(content().contentTypeCompatibleWith("text/csv")) + .andExpect(content().bytes(csvBytes)); + } +} diff --git a/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java b/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java new file mode 100644 index 00000000..b035a6e8 --- /dev/null +++ b/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java @@ -0,0 +1,44 @@ +package OneQ.OnSurvey.domain.admin.application; + +import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; +import OneQ.OnSurvey.domain.admin.domain.port.out.SurveyPort; +import OneQ.OnSurvey.domain.admin.domain.repository.AdminRepository; +import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; +import OneQ.OnSurvey.domain.survey.service.export.SurveyExport; +import OneQ.OnSurvey.global.promotion.port.out.PromotionGrantRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminFacadeExportTest { + + @Mock AdminRepository adminRepository; + @Mock MemberPort memberPort; + @Mock SurveyPort surveyPort; + @Mock PasswordEncoder passwordEncoder; + @Mock PromotionGrantRepository promotionGrantRepository; + @Mock SurveyExport surveyExport; + + @InjectMocks AdminFacade adminFacade; + + @Test + void exportSurveyCsv_delegatesToSurveyExport() { + byte[] csvBytes = "id,answer\n1,yes".getBytes(); + SurveyExportFile expected = new SurveyExportFile(csvBytes, "survey_20260504.csv", "text/csv; charset=UTF-8"); + when(surveyExport.exportCsv(42L, 0L)).thenReturn(expected); + + SurveyExportFile result = adminFacade.exportSurveyCsv(42L); + + assertThat(result.filename()).isEqualTo("survey_20260504.csv"); + assertThat(result.bytes()).isEqualTo(csvBytes); + verify(surveyExport).exportCsv(42L, 0L); + } +} From 8ade311913c17501a40b4d4ae218188b2672f398 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:29:54 +0900 Subject: [PATCH 16/32] =?UTF-8?q?refactor:=20admin=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?csv=20=EC=B6=94=EC=B6=9C=20=ED=95=A8=EC=88=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/admin/application/AdminFacade.java | 11 ++--------- .../survey/service/export/SurveyExport.java | 1 + .../service/export/SurveyExportService.java | 16 ++++++++++++---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java index dd93a86b..69259d37 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/application/AdminFacade.java @@ -7,12 +7,7 @@ import OneQ.OnSurvey.domain.admin.domain.model.Admin; import OneQ.OnSurvey.domain.admin.domain.model.AdminRole; import OneQ.OnSurvey.domain.admin.domain.model.member.AdminMemberView; -import OneQ.OnSurvey.domain.admin.domain.model.survey.AdminSurveyListView; -import OneQ.OnSurvey.domain.admin.domain.model.survey.OngoingSurveyView; -import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySingleViewInfo; -import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyQuestion; -import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveyScreening; -import OneQ.OnSurvey.domain.admin.domain.model.survey.SurveySection; +import OneQ.OnSurvey.domain.admin.domain.model.survey.*; import OneQ.OnSurvey.domain.admin.domain.port.in.AdminUseCase; import OneQ.OnSurvey.domain.admin.domain.port.in.AuthUseCase; import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; @@ -122,8 +117,6 @@ public List getOngoingSurveys() { @Override public SurveyExportFile exportSurveyCsv(Long surveyId) { - // BOSessionFilter sets ROLE_ADMIN in SecurityContext, so the ownership check inside - // SurveyExportService is bypassed via AuthorizationUtils.isAdmin(). - return surveyExport.exportCsv(surveyId, 0L); + return surveyExport.exportCsvForAdmin(surveyId); } } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExport.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExport.java index 81bf1ccd..b3482478 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExport.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExport.java @@ -4,4 +4,5 @@ public interface SurveyExport { SurveyExportFile exportCsv(Long surveyId, Long requesterId); + SurveyExportFile exportCsvForAdmin(Long surveyId); } diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java index c78b1a3f..b084a45f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/export/SurveyExportService.java @@ -11,7 +11,6 @@ import OneQ.OnSurvey.domain.survey.repository.export.SurveyExportRepository; import OneQ.OnSurvey.domain.survey.repository.surveyInfo.SurveyInfoRepository; import OneQ.OnSurvey.global.common.exception.CustomException; -import OneQ.OnSurvey.global.common.util.AuthorizationUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -32,17 +31,26 @@ public class SurveyExportService implements SurveyExport { private final SurveyExportRepository surveyExportRepository; private final SurveyInfoRepository surveyInfoRepository; + @Override + @Transactional(readOnly = true) + public SurveyExportFile exportCsvForAdmin(Long surveyId) { + log.info("[SurveyExport] CSV export start (admin). surveyId={}", surveyId); + return buildCsv(surveyId); + } + @Override @Transactional(readOnly = true) public SurveyExportFile exportCsv(Long surveyId, Long requesterMemberId) { log.info("[SurveyExport] CSV export start. surveyId={}", surveyId); - if (!AuthorizationUtils.isAdmin() && - !surveyExportRepository.existsOwnedSurvey(surveyId, requesterMemberId) - ) { + if (!surveyExportRepository.existsOwnedSurvey(surveyId, requesterMemberId)) { throw new CustomException(SURVEY_FORBIDDEN); } + return buildCsv(surveyId); + } + + private SurveyExportFile buildCsv(Long surveyId) { try { SurveyInfo surveyInfo = surveyInfoRepository.findBySurveyId(surveyId).orElseThrow(() -> new CustomException(SurveyErrorCode.SURVEY_INFO_NOT_FOUND)); boolean includeGender = shouldIncludeGender(surveyInfo); From 6545073c677d32679967214b8df142d5d4012303 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:32:27 +0900 Subject: [PATCH 17/32] =?UTF-8?q?chore:=20test=20code=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/api/AdminControllerExportTest.java | 46 ------------------- .../application/AdminFacadeExportTest.java | 44 ------------------ 2 files changed, 90 deletions(-) delete mode 100644 src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java delete mode 100644 src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java diff --git a/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java b/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java deleted file mode 100644 index c8673d54..00000000 --- a/src/test/java/OneQ/OnSurvey/domain/admin/api/AdminControllerExportTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package OneQ.OnSurvey.domain.admin.api; - -import OneQ.OnSurvey.domain.admin.application.AdminFacade; -import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import static org.hamcrest.Matchers.containsString; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@ExtendWith(MockitoExtension.class) -class AdminControllerExportTest { - - @Mock AdminFacade adminFacade; - @InjectMocks AdminController adminController; - - MockMvc mockMvc; - - @BeforeEach - void setUp() { - mockMvc = MockMvcBuilders.standaloneSetup(adminController).build(); - } - - @Test - void exportSurveyCsv_returns200WithAttachmentHeader() throws Exception { - byte[] csvBytes = "age,gender\n30,MALE\n".getBytes(); - SurveyExportFile file = new SurveyExportFile(csvBytes, "my_survey_20260504.csv", "text/csv; charset=UTF-8"); - when(adminFacade.exportSurveyCsv(7L)).thenReturn(file); - - mockMvc.perform(get("/v1/admin/surveys/7/export")) - .andExpect(status().isOk()) - .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("attachment"))) - .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("my_survey_20260504.csv"))) - .andExpect(content().contentTypeCompatibleWith("text/csv")) - .andExpect(content().bytes(csvBytes)); - } -} diff --git a/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java b/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java deleted file mode 100644 index b035a6e8..00000000 --- a/src/test/java/OneQ/OnSurvey/domain/admin/application/AdminFacadeExportTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package OneQ.OnSurvey.domain.admin.application; - -import OneQ.OnSurvey.domain.admin.domain.port.out.MemberPort; -import OneQ.OnSurvey.domain.admin.domain.port.out.SurveyPort; -import OneQ.OnSurvey.domain.admin.domain.repository.AdminRepository; -import OneQ.OnSurvey.domain.survey.model.export.SurveyExportFile; -import OneQ.OnSurvey.domain.survey.service.export.SurveyExport; -import OneQ.OnSurvey.global.promotion.port.out.PromotionGrantRepository; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.crypto.password.PasswordEncoder; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class AdminFacadeExportTest { - - @Mock AdminRepository adminRepository; - @Mock MemberPort memberPort; - @Mock SurveyPort surveyPort; - @Mock PasswordEncoder passwordEncoder; - @Mock PromotionGrantRepository promotionGrantRepository; - @Mock SurveyExport surveyExport; - - @InjectMocks AdminFacade adminFacade; - - @Test - void exportSurveyCsv_delegatesToSurveyExport() { - byte[] csvBytes = "id,answer\n1,yes".getBytes(); - SurveyExportFile expected = new SurveyExportFile(csvBytes, "survey_20260504.csv", "text/csv; charset=UTF-8"); - when(surveyExport.exportCsv(42L, 0L)).thenReturn(expected); - - SurveyExportFile result = adminFacade.exportSurveyCsv(42L); - - assertThat(result.filename()).isEqualTo("survey_20260504.csv"); - assertThat(result.bytes()).isEqualTo(csvBytes); - verify(surveyExport).exportCsv(42L, 0L); - } -} From 78c92fe08e9133870642982ee095a06e1662af15 Mon Sep 17 00:00:00 2001 From: shash0423 Date: Mon, 4 May 2026 14:46:39 +0900 Subject: [PATCH 18/32] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20CSV?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=BA=90=EC=8B=9C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=ED=97=A4=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/OneQ/OnSurvey/domain/admin/api/AdminController.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java index 502d7b6c..9ccf251b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/AdminController.java @@ -19,6 +19,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.CacheControl; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -91,6 +92,9 @@ public ResponseEntity exportSurveyCsv(@PathVariable Long surv log.info("[ADMIN] 설문 CSV 다운로드 - surveyId: {}", surveyId); SurveyExportFile file = adminFacade.exportSurveyCsv(surveyId); return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header(HttpHeaders.PRAGMA, "no-cache") + .header(HttpHeaders.EXPIRES, "0") .contentType(MediaType.parseMediaType(file.contentType())) .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() From 3ae8957e07d73ebe7a23f9113d6c9d7ecff4ee16 Mon Sep 17 00:00:00 2001 From: jaekwan Date: Tue, 5 May 2026 10:43:40 +0900 Subject: [PATCH 19/32] =?UTF-8?q?fix:=20=ED=99=88=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=BD=94=EC=9D=B8=20=EC=B0=A8=EA=B0=90=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../survey/service/command/SurveyCommand.java | 1 + .../service/command/SurveyCommandService.java | 36 +++++++++++++++++++ .../formRequest/FormEventListener.java | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java index 5b07b9f0..2aea4928 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommand.java @@ -14,6 +14,7 @@ public interface SurveyCommand { SurveyFormResponse upsertSurvey(Long memberId, Long surveyId, SurveyFormCreateRequest request); SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRequest request); + SurveyFormResponse submitHomeFormSurvey(Long userKey, Long surveyId, SurveyFormRequest request); SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurveyFormRequest request); ScreeningResponse upsertScreening(Long surveyId, String content, Boolean answer); Boolean refundSurvey(Long memberId, Long surveyId); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java index 05441414..8d899699 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/command/SurveyCommandService.java @@ -160,6 +160,42 @@ public SurveyFormResponse submitSurvey(Long userKey, Long surveyId, SurveyFormRe } + @Override + public SurveyFormResponse submitHomeFormSurvey(Long userKey, Long surveyId, SurveyFormRequest request) { + Survey survey = getSurvey(surveyId); + validateMember(userKey); + + Set ages = (request.ages() == null) ? Set.of() : new HashSet<>(request.ages()); + + Long discountCodeId = null; + if (request.discountCode() != null && !request.discountCode().isBlank()) { + DiscountCode discountCode = discountCodeQueryService.getByCode(request.discountCode()); + discountCodeId = discountCode.getId(); + log.info("[HomeFormSubmit] 할인 코드 저장 - surveyId={}, org={}", surveyId, discountCode.getOrganizationName()); + } + + survey.updateSurvey(survey.getTitle(), survey.getDescription(), request.deadline(), request.totalCoin()); + + int questionCount = questionQueryService.countQuestionsBySurveyId(surveyId); + int resolvedPromotionAmount = promotionTierResolver.resolveAmountByQuestionCount(questionCount); + + Set residences = (request.residences() == null) ? Set.of() : new HashSet<>(request.residences()); + + SurveyInfo info = upsertSurveyInfo( + surveyId, + request.dueCount(), + request.gender(), + ages, + residences, + resolvedPromotionAmount, + discountCodeId, + true + ); + + log.info("[HomeFormSubmit] 홈결제 설문 제출 완료 (코인 차감 없음) - surveyId={}", surveyId); + return finalizeSubmit(userKey, surveyId, survey, info, request.dueCount(), request.deadline(), request.totalCoin()); + } + @Override public SurveyFormResponse submitFreeSurvey(Long userKey, Long surveyId, FreeSurveyFormRequest request) { Survey survey = getSurvey(surveyId); diff --git a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java index ad0b2391..a84e152f 100644 --- a/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java +++ b/src/main/java/OneQ/OnSurvey/domain/survey/service/formRequest/FormEventListener.java @@ -102,7 +102,7 @@ public void convertGoogleFormIntoSurvey(FormRequestConversionEvent event) { } else { surveyCommand.upsertInterest(conversionSurveyId, Set.of(Interest.BUSINESS)); } - surveyCommand.submitSurvey(event.userKey(), conversionSurveyId, event.surveyForm()); + surveyCommand.submitHomeFormSurvey(event.userKey(), conversionSurveyId, event.surveyForm()); successCount.incrementAndGet(); return SurveyConversionAlert.ConversionDetails.success( From 5b9812ea5055d40ab6d9bdef4b347f03971aa0d9 Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Thu, 30 Apr 2026 13:55:05 +0900 Subject: [PATCH 20/32] =?UTF-8?q?docs:=20`QuestionDto`=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=ED=83=80=EC=9E=85=EC=9D=B4=20=EC=A0=95=EC=83=81?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EB=AC=B8=EC=84=9C=ED=99=94?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84=EC=83=81=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 --- .../OnSurvey/domain/question/model/dto/type/ChoiceDto.java | 7 +++++++ .../OnSurvey/domain/question/model/dto/type/DateDto.java | 7 +++++++ .../domain/question/model/dto/type/DefaultQuestionDto.java | 2 +- .../OnSurvey/domain/question/model/dto/type/GridDto.java | 7 +++++++ .../OnSurvey/domain/question/model/dto/type/RatingDto.java | 7 +++++++ .../OnSurvey/domain/question/model/dto/type/TimeDto.java | 7 +++++++ 6 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/ChoiceDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/ChoiceDto.java index 9f05a1cb..f19f66b7 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/ChoiceDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/ChoiceDto.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.domain.question.entity.question.Choice; import OneQ.OnSurvey.domain.question.model.dto.OptionDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -41,4 +42,10 @@ public static ChoiceDto fromEntity(Choice choice) { public void updateOptions(List optionInfoList) { this.options = optionInfoList; } + + @Schema(description = "문항 타입 유형", example = "CHOICE") + @Override + public String getQuestionType() { + return super.getQuestionType(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DateDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DateDto.java index ea5ccb05..cf6585fb 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DateDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DateDto.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.question.model.dto.type; import OneQ.OnSurvey.domain.question.entity.question.DateAnswer; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -28,4 +29,10 @@ public static DateDto fromEntity(DateAnswer date) { .imageUrl(date.getImageUrl()) .build(); } + + @Schema(description = "문항 타입 유형", example = "DATE") + @Override + public String getQuestionType() { + return super.getQuestionType(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java index a942f5e1..782e4b35 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/DefaultQuestionDto.java @@ -35,7 +35,7 @@ public class DefaultQuestionDto { @Schema( description = "문항 타입 유형", allowableValues = { - "CHOICE", "RATING", "NPS", "SHORT", "LONG", "NUMBER", "DATE", "IMAGE", "TITLE", "GRID", "TIME" + "SHORT", "LONG", "NUMBER", "IMAGE", "TITLE", "NPS", "CHOICE", "RATING", "DATE", "GRID", "TIME" } ) private String questionType; diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java index 7251c906..58c4aac1 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/GridDto.java @@ -2,6 +2,7 @@ import OneQ.OnSurvey.domain.question.entity.question.Grid; import OneQ.OnSurvey.domain.question.model.dto.GridOptionDto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -39,4 +40,10 @@ public static GridDto fromEntity(Grid grid) { public void updateGridOptions(List gridOptions) { this.gridOptions = gridOptions; } + + @Schema(description = "문항 타입 유형", example = "GRID") + @Override + public String getQuestionType() { + return super.getQuestionType(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/RatingDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/RatingDto.java index 2d7e4cc0..d5e047b6 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/RatingDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/RatingDto.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.question.model.dto.type; import OneQ.OnSurvey.domain.question.entity.question.Rating; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -30,4 +31,10 @@ public static RatingDto fromEntity(Rating rating) { .imageUrl(rating.getImageUrl()) .build(); } + + @Schema(description = "문항 타입 유형", example = "RATING") + @Override + public String getQuestionType() { + return super.getQuestionType(); + } } diff --git a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java index 24c7adba..e15137e8 100644 --- a/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java +++ b/src/main/java/OneQ/OnSurvey/domain/question/model/dto/type/TimeDto.java @@ -1,6 +1,7 @@ package OneQ.OnSurvey.domain.question.model.dto.type; import OneQ.OnSurvey.domain.question.entity.question.Time; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,4 +27,10 @@ public static TimeDto fromEntity(Time time) { .imageUrl(time.getImageUrl()) .build(); } + + @Schema(description = "문항 타입 유형", example = "TIME") + @Override + public String getQuestionType() { + return super.getQuestionType(); + } } From fc48c7689de5a250220be2cf1ff6c5f0227f1d2c Mon Sep 17 00:00:00 2001 From: wonjuneee Date: Sat, 2 May 2026 21:38:58 +0900 Subject: [PATCH 21/32] =?UTF-8?q?mod:=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EA=B7=B8=EB=A6=AC=EB=93=9C/=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=AC=B8=ED=95=AD=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/AdminSurveyDetailResponse.java | 41 +++++++++++++++++-- .../domain/model/survey/SurveyQuestion.java | 2 + .../admin/infra/mapper/AdminSurveyMapper.java | 6 ++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java index 744f8528..61de6b2b 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/api/dto/response/AdminSurveyDetailResponse.java @@ -76,7 +76,9 @@ public record QuestionDto( String imageUrl, ChoicePropDto choiceProperty, RatingPropDto ratingProperty, - DatePropDto dateProperty + DatePropDto dateProperty, + GridPropDto gridProperty, + TimePropDto timeProperty ) { public static QuestionDto from(SurveyQuestion vo) { if (vo == null) return null; @@ -91,7 +93,9 @@ public static QuestionDto from(SurveyQuestion vo) { vo.imageUrl(), ChoicePropDto.from(vo.choiceProperty()), RatingPropDto.from(vo.ratingProperty()), - DatePropDto.from(vo.dateProperty()) + DatePropDto.from(vo.dateProperty()), + GridPropDto.from(vo.gridProperty()), + TimePropDto.from(vo.timeProperty()) ); } @@ -110,10 +114,10 @@ public static ChoicePropDto from(SurveyQuestion.ChoiceProp vo) { return new ChoicePropDto(vo.maxChoice(), vo.hasCustomInput(), vo.hasNoneOption(), vo.isSectionDecidable(), optionDtos); } - public record OptionDto(String content, Integer nextSection, String imageUrl) { + public record OptionDto(Long optionId, String content, Integer nextSection, String imageUrl) { public static OptionDto from(SurveyQuestion.ChoiceProp.Option vo) { if (vo == null) return null; - return new OptionDto(vo.content(), vo.nextSection(), vo.imageUrl()); + return new OptionDto(vo.optionId(), vo.content(), vo.nextSection(), vo.imageUrl()); } } } @@ -131,6 +135,35 @@ public static DatePropDto from(SurveyQuestion.DateProp vo) { return new DatePropDto(vo.defaultDate()); } } + + public record TimePropDto(Boolean isInterval) { + public static TimePropDto from(SurveyQuestion.TimeProp vo) { + if (vo == null) return null; + return new TimePropDto(vo.isInterval()); + } + } + + public record GridPropDto( + Boolean isCheckbox, + Boolean isChoiceMixed, + Boolean isChoiceDistinct, + Set gridOptions + ) { + public static GridPropDto from(SurveyQuestion.GridProp vo) { + if (vo == null) return null; + Set optionDtos = vo.gridOptions() != null + ? vo.gridOptions().stream().map(GridOptionDto::from).collect(Collectors.toSet()) + : Set.of(); + return new GridPropDto(vo.isCheckbox(), vo.isChoiceMixed(), vo.isChoiceDistinct(), optionDtos); + } + + public record GridOptionDto(Long gridOptionId, Boolean isRow, String content, Integer order) { + public static GridOptionDto from(SurveyQuestion.GridProp.GridOption vo) { + if (vo == null) return null; + return new GridOptionDto(vo.gridOptionId(), vo.isRow(), vo.content(), vo.order()); + } + } + } } public record ScreeningDto( diff --git a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java index a35da5e0..0d8408d5 100644 --- a/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java +++ b/src/main/java/OneQ/OnSurvey/domain/admin/domain/model/survey/SurveyQuestion.java @@ -31,6 +31,7 @@ public record ChoiceProp ( Set