From 17fe26a6069333d3ddcb0b83a74644ab2ebb0fa8 Mon Sep 17 00:00:00 2001 From: Lee Ilseob Date: Thu, 27 Aug 2026 17:46:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(stats):=20=EC=A7=88=EB=AC=B8=20revision=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B0=98=EC=9D=91=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(db): 질문 리비전과 노출 이벤트 저장 기반 추가 daily_questions의 현재 상태를 revision 1 baseline으로 저장하고 이후 질문 수정 이력을 보존할 수 있도록 구성함. baseline 이후의 INITIAL·REROLL 노출만 기록하도록 제한함 * feat(report): 질문 노출 이벤트를 할당과 답변 흐름에 연결 V1·V2 질문 할당과 교체 시 INITIAL·REROLL 노출을 저장하고 답변 시 노출 revision을 AnswerEntry에 연결함. baseline 이전 할당은 자동 편입하지 않으며 질문 조회와 LLM 입력이 실제 노출 revision을 사용하도록 수정함 * feat(stats): 질문 revision별 반응 통계 조회 추가 질문별 누적 노출·답변·교체·미응답과 답변 비율을 집계함. daily_question_revisions 기준 LEFT JOIN으로 노출이 없는 revision도 수정 이력에 포함하고 선택 질문의 전체 합계 ViewModel을 제공함 * feat(stats): 질문 반응 통계 대시보드 추가 /stats/question에서 질문 ID·문구 검색과 전체 및 revision별 노출·답변·교체·미응답 지표를 조회하도록 추가함. 모든 stats 화면에 질문 탭을 연결하고 Revision·baseline 해석 가이드, 반응형 검색 결과 목록, MVC 템플릿 회귀 테스트를 함께 추가함 * feat(stats): 질문 전체 현황 조회 기반 추가 Question ID별 현재 Revision 반응과 전체 Revision 누적을 분리 집계하는 read model을 추가함. 질문 검색·관심사·레벨·활성 상태·최소 노출 필터와 반응 지표별 정렬을 지원하고 repository 통합 테스트와 service 테스트로 집계 및 표본 필터 동작을 검증함 * feat(stats): 질문 전체 통계 표 대시보드 추가 현재 Revision 반응과 전체 Revision 누적을 비교하는 전체 질문 현황 표를 추가하고 검색·필터·양방향 정렬 및 상세 화면 이동을 지원함. 질문 상세를 기본 탭으로 유지하고 오름차순 화살표를 빨간색으로 구분하며 조회 조건 검증과 Thymeleaf 렌더링 테스트를 보강함 * feat(stats): 질문 통계 CSV 다운로드와 집계 기준 표시 추가 전체 질문 현황의 필터·정렬 조건을 재사용하는 Excel 호환 CSV 다운로드를 구현하고, 최초 Revision 1 적용 시각을 질문 반응 통계의 집계 시작 기준으로 표시함 --- .../application/DailyReportService.java | 11 +- .../application/DailyReportTxService.java | 33 +- .../core/dto/PrepareDailyResultDto.java | 3 +- .../dailyreport/core/entity/AnswerEntry.java | 13 +- .../core/service/AnswerEntryService.java | 20 +- .../application/QuestionCommandService.java | 40 +- .../application/QuestionCommandServiceV2.java | 42 +- .../question/core/entity/DailyQuestion.java | 3 + .../core/entity/DailyQuestionExposure.java | 90 ++ .../entity/DailyQuestionExposureSource.java | 6 + .../core/entity/DailyQuestionRevision.java | 64 ++ .../DailyQuestionExposureRepository.java | 11 + .../DailyQuestionRevisionRepository.java | 11 + .../service/DailyQuestionExposureService.java | 83 ++ .../application/QuestionStatsService.java | 149 +++ .../DailyQuestionOverviewCsvExporter.java | 93 ++ .../stats/controller/StatsController.java | 114 +++ .../DailyQuestionListItemViewModel.java | 19 + .../question/DailyQuestionOverviewQuery.java | 41 + .../DailyQuestionOverviewRowViewModel.java | 56 ++ .../question/DailyQuestionOverviewSort.java | 15 + .../DailyQuestionOverviewSortDirection.java | 6 + .../DailyQuestionOverviewViewModel.java | 18 + .../DailyQuestionReactionStatsViewModel.java | 17 + .../DailyQuestionRevisionStatsViewModel.java | 36 + .../question/DailyQuestionStatsViewModel.java | 15 + .../repository/QuestionStatsRepository.java | 149 +++ ...daily_question_revisions_and_exposures.sql | 99 ++ src/main/resources/templates/stats/daily.html | 3 + .../resources/templates/stats/monthly.html | 3 + .../templates/stats/question-overview.html | 594 ++++++++++++ .../resources/templates/stats/question.html | 858 ++++++++++++++++++ src/main/resources/templates/stats/total.html | 3 + src/main/resources/templates/stats/type.html | 3 + .../resources/templates/stats/weekly.html | 3 + .../resources/templates/stats/withdrawal.html | 1 + .../application/DailyReportServiceTest.java | 19 +- .../application/DailyReportTxServiceTest.java | 97 +- .../QuestionCommandExposureServiceTest.java | 249 +++++ .../DailyQuestionExposureRepositoryTest.java | 153 ++++ .../DailyQuestionRevisionRepositoryTest.java | 150 +++ .../DailyQuestionExposureServiceTest.java | 136 +++ .../application/QuestionStatsServiceTest.java | 272 ++++++ .../DailyQuestionOverviewCsvExporterTest.java | 78 ++ .../StatsControllerTemplateTest.java | 321 +++++++ .../QuestionStatsRepositoryTest.java | 249 +++++ 46 files changed, 4398 insertions(+), 51 deletions(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposure.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposureSource.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionRevision.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepository.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsService.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporter.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionListItemViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewQuery.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewRowViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSort.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSortDirection.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionReactionStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionRevisionStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionStatsViewModel.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepository.java create mode 100644 src/main/resources/db/migration/V20260825_1200__IS_create_daily_question_revisions_and_exposures.sql create mode 100644 src/main/resources/templates/stats/question-overview.html create mode 100644 src/main/resources/templates/stats/question.html create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandExposureServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepositoryTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepositoryTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsServiceTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporterTest.java create mode 100644 src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepositoryTest.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java index e78b7cab..638bba59 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java @@ -88,7 +88,14 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq throw new BadRequestException(ErrorCode.DAILY_QUESTION_MISMATCH); } - PrepareDailyResultDto prep = dailyReportTxService.prepareDaily(user, question, request.answer(), isDayPassed, request.objectKey()); + PrepareDailyResultDto prep = dailyReportTxService.prepareDaily( + user, + question, + udq, + request.answer(), + isDayPassed, + request.objectKey() + ); AnswerEntry answerEntry = prep.entry(); ModelCandidate modelCandidate = dailyReportModelSelector.select(); @@ -104,7 +111,7 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq AiDailyReportResultDto dto; try { LlmGenerationResult generationResult = - dailyReportLlmClient.generate(question.getQuestionText(), answerEntry, modelCandidate); + dailyReportLlmClient.generate(prep.questionText(), answerEntry, modelCandidate); dto = generationResult.content(); LlmTokenUsage tokenUsage = generationResult.tokenUsage(); reportGenerationLogRecorder.recordTokenUsage( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java index 9af55443..95fd8381 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxService.java @@ -13,6 +13,9 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.service.AnswerEntryService; import com.devkor.ifive.nadab.domain.dailyreport.core.service.PendingDailyReportService; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.service.DailyQuestionExposureService; import com.devkor.ifive.nadab.domain.user.core.entity.Interest; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.entity.User; @@ -45,20 +48,42 @@ public class DailyReportTxService { private final AnswerEntryService answerEntryService; private final PendingDailyReportService pendingDailyReportService; + private final DailyQuestionExposureService dailyQuestionExposureService; private static final long DAILY_REPORT_REWARD = 10L; - protected PrepareDailyResultDto prepareDaily(User user, DailyQuestion dq, String answerText, boolean isDayPassed, - @Nullable String imageKey) { + protected PrepareDailyResultDto prepareDaily( + User user, + DailyQuestion dq, + UserDailyQuestion assignment, + String answerText, + boolean isDayPassed, + @Nullable String imageKey + ) { + + DailyQuestionRevision questionRevision = dailyQuestionExposureService + .recordAnswer(assignment) + .orElse(null); // AnswerEntry 생성 또는 조회 (별도의 트랜잭션) - AnswerEntry entry = answerEntryService.getOrCreateTodayAnswerEntry(user, dq, answerText, isDayPassed, imageKey); + AnswerEntry entry = answerEntryService.getOrCreateTodayAnswerEntry( + user, + dq, + questionRevision, + answerText, + isDayPassed, + imageKey + ); // DailyReport PENDING 생성 또는 조회 (별도의 트랜잭션) DailyReport report = pendingDailyReportService.getOrCreatePendingDailyReport(entry, isDayPassed); - return new PrepareDailyResultDto(entry, report.getId(), user.getId()); + String questionText = entry.getQuestionRevision() != null + ? entry.getQuestionRevision().getQuestionText() + : dq.getQuestionText(); + + return new PrepareDailyResultDto(entry, report.getId(), user.getId(), questionText); } protected ConfirmDailyAndRewardDto confirmDailyAndReward( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/PrepareDailyResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/PrepareDailyResultDto.java index 648798b1..95fddb87 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/PrepareDailyResultDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/PrepareDailyResultDto.java @@ -5,6 +5,7 @@ public record PrepareDailyResultDto( AnswerEntry entry, Long reportId, - Long userId + Long userId, + String questionText ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java index 24942a89..1a574414 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/entity/AnswerEntry.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.dailyreport.core.entity; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; import jakarta.persistence.*; @@ -34,6 +35,10 @@ public class AnswerEntry extends AuditableEntity { @JoinColumn(name = "question_id", nullable = false) private DailyQuestion question; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "question_revision_id") + private DailyQuestionRevision questionRevision; + @Column(name = "content", length = 500, nullable = false) private String content; @@ -45,9 +50,15 @@ public class AnswerEntry extends AuditableEntity { public static AnswerEntry create(User user, DailyQuestion question, String content, LocalDate date, @Nullable String imageKey) { + return create(user, question, null, content, date, imageKey); + } + + public static AnswerEntry create(User user, DailyQuestion question, @Nullable DailyQuestionRevision questionRevision, + String content, LocalDate date, @Nullable String imageKey) { AnswerEntry e = new AnswerEntry(); e.user = user; e.question = question; + e.questionRevision = questionRevision; e.content = content; e.date = date; e.imageKey = imageKey; @@ -64,4 +75,4 @@ public void updateImageKey(String imageKey) { this.imageKey = imageKey; onUpdate(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/service/AnswerEntryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/service/AnswerEntryService.java index de37a9f8..a002694e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/service/AnswerEntryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/service/AnswerEntryService.java @@ -3,6 +3,7 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; @@ -22,8 +23,14 @@ public class AnswerEntryService { @Transactional - public AnswerEntry getOrCreateTodayAnswerEntry(User user, DailyQuestion dq, String answerText, boolean isDayPassed, - @Nullable String imageKey) { + public AnswerEntry getOrCreateTodayAnswerEntry( + User user, + DailyQuestion dq, + @Nullable DailyQuestionRevision questionRevision, + String answerText, + boolean isDayPassed, + @Nullable String imageKey + ) { LocalDate targetDate = isDayPassed ? TodayDateTimeProvider.getTodayDate().minusDays(1) : TodayDateTimeProvider.getTodayDate(); @@ -35,7 +42,14 @@ public AnswerEntry getOrCreateTodayAnswerEntry(User user, DailyQuestion dq, Stri }) .orElseGet(() -> { try { - return answerEntryRepository.save(AnswerEntry.create(user, dq, answerText, targetDate, imageKey)); + return answerEntryRepository.save(AnswerEntry.create( + user, + dq, + questionRevision, + answerText, + targetDate, + imageKey + )); } catch (DataIntegrityViolationException e) { // 동시 요청에서 이미 누가 만들었을 수 있음 -> 재조회로 멱등 처리 return answerEntryRepository.findByUserAndDate(user, targetDate) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandService.java index 9fe0aa89..4a1b1b1a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandService.java @@ -4,8 +4,10 @@ import com.devkor.ifive.nadab.domain.question.application.helper.DailyQuestionSelector; import com.devkor.ifive.nadab.domain.question.application.helper.QuestionLevelPolicy; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.question.core.service.DailyQuestionExposureService; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserInterestRepository; @@ -33,6 +35,7 @@ public class QuestionCommandService { private final QuestionLevelPolicy questionLevelPolicy; private final DailyQuestionSelector dailyQuestionSelector; + private final DailyQuestionExposureService dailyQuestionExposureService; public DailyQuestionResponse getOrCreateTodayQuestion(Long userId) { @@ -44,16 +47,21 @@ public DailyQuestionResponse getOrCreateTodayQuestion(Long userId) { .orElseGet(() -> this.createTodayQuestion(userId, today)); DailyQuestion question = udq.getDailyQuestion(); + DailyQuestionRevision revision = dailyQuestionExposureService + .findLatestRevision(udq) + .orElse(null); boolean answered = answerEntryRepository.existsActiveAnswer(userId, question.getId()); return new DailyQuestionResponse( question.getId(), - question.getInterest().getCode().toString(), - question.getQuestionText(), - question.getEmpathyGuide(), - question.getHintGuide(), - question.getLeadingQuestionGuide(), + revision != null + ? revision.getInterest().getCode().toString() + : question.getInterest().getCode().toString(), + revision != null ? revision.getQuestionText() : question.getQuestionText(), + revision != null ? revision.getEmpathyGuide() : question.getEmpathyGuide(), + revision != null ? revision.getHintGuide() : question.getHintGuide(), + revision != null ? revision.getLeadingQuestionGuide() : question.getLeadingQuestionGuide(), answered, udq.isRerollUsed() ); @@ -68,25 +76,28 @@ public DailyQuestionResponse getOrCreateTodayQuestion(Long userId) { public UserDailyQuestion createTodayQuestion(Long userId, LocalDate todayKst) { // 동시성: 여러 요청이 동시에 들어오면 UNIQUE(user_id, date)로 한 번만 성공해야 함 // -> insert 시도 후 unique 위반이면 다시 조회해서 반환 - try { - User user = userRepository.findById(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); + Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); - Integer levelOnly = questionLevelPolicy.levelOnlyFor(user, OffsetDateTime.now()); + Integer levelOnly = questionLevelPolicy.levelOnlyFor(user, OffsetDateTime.now()); - DailyQuestion picked = dailyQuestionSelector.pickFirst(user.getId(), userInterestId, levelOnly); + DailyQuestion picked = dailyQuestionSelector.pickFirst(user.getId(), userInterestId, levelOnly); + UserDailyQuestion saved; + try { UserDailyQuestion udq = UserDailyQuestion.create(user, todayKst, picked); - return userDailyQuestionRepository.save(udq); - + saved = userDailyQuestionRepository.save(udq); } catch (DataIntegrityViolationException e) { // 이미 생성됨(경합 상황) return userDailyQuestionRepository.findByUserIdAndDate(userId, todayKst) .orElseThrow(() -> e); } + + dailyQuestionExposureService.recordInitialAssignment(saved); + return saved; } /** @@ -126,6 +137,7 @@ public DailyQuestionResponse rerollTodayQuestion(Long userId) { ); udq.rerollTo(newQ); + dailyQuestionExposureService.recordReroll(udq, newQ); return new DailyQuestionResponse( newQ.getId(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java index c2385c8f..0ef4c882 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandServiceV2.java @@ -5,8 +5,10 @@ import com.devkor.ifive.nadab.domain.question.application.helper.DailyQuestionSelector; import com.devkor.ifive.nadab.domain.question.application.helper.QuestionLevelPolicy; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.question.core.service.DailyQuestionExposureService; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserInterestRepository; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; @@ -33,6 +35,7 @@ public class QuestionCommandServiceV2 { private final QuestionLevelPolicy questionLevelPolicy; private final DailyQuestionSelector dailyQuestionSelector; + private final DailyQuestionExposureService dailyQuestionExposureService; public DailyQuestionResponseV2 getOrCreateTodayQuestion(Long userId) { @@ -44,16 +47,21 @@ public DailyQuestionResponseV2 getOrCreateTodayQuestion(Long userId) { .orElseGet(() -> this.createTodayQuestion(userId, today)); DailyQuestion question = udq.getDailyQuestion(); + DailyQuestionRevision revision = dailyQuestionExposureService + .findLatestRevision(udq) + .orElse(null); boolean answered = answerEntryRepository.existsActiveAnswer(userId, question.getId()); return new DailyQuestionResponseV2( question.getId(), - question.getInterest().getCode().toString(), - question.getQuestionText(), - question.getEmpathyGuide(), - question.getHintGuide(), - question.getLeadingQuestionGuide(), + revision != null + ? revision.getInterest().getCode().toString() + : question.getInterest().getCode().toString(), + revision != null ? revision.getQuestionText() : question.getQuestionText(), + revision != null ? revision.getEmpathyGuide() : question.getEmpathyGuide(), + revision != null ? revision.getHintGuide() : question.getHintGuide(), + revision != null ? revision.getLeadingQuestionGuide() : question.getLeadingQuestionGuide(), answered, udq.getRerollLeft() ); @@ -62,27 +70,30 @@ public DailyQuestionResponseV2 getOrCreateTodayQuestion(Long userId) { public UserDailyQuestion createTodayQuestion(Long userId, LocalDate todayKst) { // 동시성: 여러 요청이 동시에 들어오면 UNIQUE(user_id, date)로 한 번만 성공해야 함 // -> insert 시도 후 unique 위반이면 다시 조회해서 반환 - try { - User user = userRepository.findById(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); + Long userInterestId = userInterestRepository.findInterestIdByUserId(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_INTEREST_NOT_FOUND)); - boolean isFirstQuestion = !(userDailyQuestionRepository.existsByUserId(userId)); + boolean isFirstQuestion = !(userDailyQuestionRepository.existsByUserId(userId)); - Integer levelOnly = questionLevelPolicy.levelOnlyForFirstTime(isFirstQuestion); + Integer levelOnly = questionLevelPolicy.levelOnlyForFirstTime(isFirstQuestion); - DailyQuestion picked = dailyQuestionSelector.pickFirst(user.getId(), userInterestId, levelOnly); + DailyQuestion picked = dailyQuestionSelector.pickFirst(user.getId(), userInterestId, levelOnly); + UserDailyQuestion saved; + try { UserDailyQuestion udq = UserDailyQuestion.create(user, todayKst, picked); - return userDailyQuestionRepository.save(udq); - + saved = userDailyQuestionRepository.save(udq); } catch (DataIntegrityViolationException e) { // 이미 생성됨(경합 상황) return userDailyQuestionRepository.findByUserIdAndDate(userId, todayKst) .orElseThrow(() -> e); } + + dailyQuestionExposureService.recordInitialAssignment(saved); + return saved; } public DailyQuestionResponseV2 rerollTodayQuestion(Long userId) { @@ -113,6 +124,7 @@ public DailyQuestionResponseV2 rerollTodayQuestion(Long userId) { ); udq.rerollTo(newQ); + dailyQuestionExposureService.recordReroll(udq, newQ); return new DailyQuestionResponseV2( newQ.getId(), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestion.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestion.java index 7c1a9b90..419899c4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestion.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestion.java @@ -33,4 +33,7 @@ public class DailyQuestion extends SoftDeletableEntity { @Column(name = "leading_question_guide", length = 100) private String leadingQuestionGuide; + + @Column(name = "current_revision_no", nullable = false) + private int currentRevisionNo; } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposure.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposure.java new file mode 100644 index 00000000..46eec860 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposure.java @@ -0,0 +1,90 @@ +package com.devkor.ifive.nadab.domain.question.core.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "daily_question_exposures", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_daily_question_exposures_assignment_sequence", + columnNames = {"user_daily_question_id", "sequence"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyQuestionExposure { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_daily_question_id") + private UserDailyQuestion userDailyQuestion; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_question_revision_id", nullable = false) + private DailyQuestionRevision dailyQuestionRevision; + + @Column(name = "assignment_date", nullable = false) + private LocalDate assignmentDate; + + @Column(name = "sequence", nullable = false) + private int sequence; + + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 30) + private DailyQuestionExposureSource source; + + @Column(name = "assigned_at", nullable = false) + private OffsetDateTime assignedAt; + + @Column(name = "rerolled_at") + private OffsetDateTime rerolledAt; + + @Column(name = "answered_at") + private OffsetDateTime answeredAt; + + public static DailyQuestionExposure create( + UserDailyQuestion userDailyQuestion, + DailyQuestionRevision dailyQuestionRevision, + LocalDate assignmentDate, + int sequence, + DailyQuestionExposureSource source + ) { + DailyQuestionExposure exposure = new DailyQuestionExposure(); + exposure.userDailyQuestion = userDailyQuestion; + exposure.dailyQuestionRevision = dailyQuestionRevision; + exposure.assignmentDate = assignmentDate; + exposure.sequence = sequence; + exposure.source = source; + exposure.assignedAt = OffsetDateTime.now(); + return exposure; + } + + public boolean isOpen() { + return rerolledAt == null && answeredAt == null; + } + + public void markRerolled() { + if (!isOpen()) { + throw new IllegalStateException("Only an open daily question exposure can be rerolled"); + } + rerolledAt = OffsetDateTime.now(); + } + + public void markAnswered() { + if (!isOpen()) { + throw new IllegalStateException("Only an open daily question exposure can be answered"); + } + answeredAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposureSource.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposureSource.java new file mode 100644 index 00000000..6593de2e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionExposureSource.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.question.core.entity; + +public enum DailyQuestionExposureSource { + INITIAL, + REROLL +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionRevision.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionRevision.java new file mode 100644 index 00000000..31a37b12 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/entity/DailyQuestionRevision.java @@ -0,0 +1,64 @@ +package com.devkor.ifive.nadab.domain.question.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.Interest; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "daily_question_revisions", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_daily_question_revisions_question_revision", + columnNames = {"daily_question_id", "revision_no"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyQuestionRevision extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_question_id", nullable = false) + private DailyQuestion dailyQuestion; + + @Column(name = "revision_no", nullable = false) + private int revisionNo; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "interest_id") + private Interest interest; + + @Column(name = "question_text", nullable = false, length = 100) + private String questionText; + + @Column(name = "question_level", nullable = false) + private int questionLevel; + + @Column(name = "empathy_guide", length = 100) + private String empathyGuide; + + @Column(name = "hint_guide", length = 100) + private String hintGuide; + + @Column(name = "leading_question_guide", length = 100) + private String leadingQuestionGuide; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + @Column(name = "effective_from", nullable = false) + private OffsetDateTime effectiveFrom; + + @Column(name = "source_migration", nullable = false, length = 100) + private String sourceMigration; +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepository.java new file mode 100644 index 00000000..8ef31706 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepository.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.question.core.repository; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposure; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DailyQuestionExposureRepository extends JpaRepository { + + Optional findTopByUserDailyQuestion_IdOrderBySequenceDesc(Long userDailyQuestionId); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepository.java new file mode 100644 index 00000000..6a1aa9ee --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepository.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.question.core.repository; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface DailyQuestionRevisionRepository extends JpaRepository { + + Optional findByDailyQuestion_IdAndRevisionNo(Long dailyQuestionId, int revisionNo); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureService.java b/src/main/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureService.java new file mode 100644 index 00000000..48ce7cc3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureService.java @@ -0,0 +1,83 @@ +package com.devkor.ifive.nadab.domain.question.core.service; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposure; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposureSource; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionExposureRepository; +import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRevisionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class DailyQuestionExposureService { + + private final DailyQuestionExposureRepository exposureRepository; + private final DailyQuestionRevisionRepository revisionRepository; + + public void recordInitialAssignment(UserDailyQuestion assignment) { + DailyQuestionRevision revision = currentRevisionOf(assignment.getDailyQuestion()); + exposureRepository.save(DailyQuestionExposure.create( + assignment, + revision, + assignment.getDate(), + 0, + DailyQuestionExposureSource.INITIAL + )); + } + + public void recordReroll(UserDailyQuestion assignment, DailyQuestion newQuestion) { + Optional latestExposure = latestExposureOf(assignment); + int nextSequence = latestExposure.map(exposure -> exposure.getSequence() + 1).orElse(0); + + latestExposure.filter(DailyQuestionExposure::isOpen).ifPresent(exposure -> { + exposure.markRerolled(); + exposureRepository.flush(); + }); + + DailyQuestionRevision revision = currentRevisionOf(newQuestion); + exposureRepository.save(DailyQuestionExposure.create( + assignment, + revision, + assignment.getDate(), + nextSequence, + DailyQuestionExposureSource.REROLL + )); + } + + public Optional recordAnswer(UserDailyQuestion assignment) { + return latestExposureOf(assignment) + .filter(DailyQuestionExposure::isOpen) + .map(exposure -> { + exposure.markAnswered(); + return exposure.getDailyQuestionRevision(); + }); + } + + @Transactional(readOnly = true) + public Optional findLatestRevision(UserDailyQuestion assignment) { + return latestExposureOf(assignment).map(DailyQuestionExposure::getDailyQuestionRevision); + } + + private Optional latestExposureOf(UserDailyQuestion assignment) { + if (assignment.getId() == null) { + return Optional.empty(); + } + return exposureRepository.findTopByUserDailyQuestion_IdOrderBySequenceDesc(assignment.getId()); + } + + private DailyQuestionRevision currentRevisionOf(DailyQuestion question) { + return revisionRepository + .findByDailyQuestion_IdAndRevisionNo(question.getId(), question.getCurrentRevisionNo()) + .orElseThrow(() -> new IllegalStateException( + "Current daily question revision not found: questionId=%d, revisionNo=%d" + .formatted(question.getId(), question.getCurrentRevisionNo()) + )); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsService.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsService.java new file mode 100644 index 00000000..b7e4d25b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsService.java @@ -0,0 +1,149 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSortDirection; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionReactionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.QuestionStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Nullable; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class QuestionStatsService { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final QuestionStatsRepository repository; + + public DailyQuestionStatsViewModel getQuestionStats(@Nullable Long questionId) { + List questions = repository.findQuestions(); + DailyQuestionListItemViewModel selectedQuestion = selectQuestion(questions, questionId); + List revisions = selectedQuestion == null + ? List.of() + : repository.findRevisionStats(selectedQuestion.questionId()); + + long exposureCount = revisions.stream() + .mapToLong(DailyQuestionRevisionStatsViewModel::exposureCount) + .sum(); + long answeredCount = revisions.stream() + .mapToLong(DailyQuestionRevisionStatsViewModel::answeredCount) + .sum(); + long rerolledCount = revisions.stream() + .mapToLong(DailyQuestionRevisionStatsViewModel::rerolledCount) + .sum(); + long unansweredCount = revisions.stream() + .mapToLong(DailyQuestionRevisionStatsViewModel::unansweredCount) + .sum(); + + return new DailyQuestionStatsViewModel( + questions, + selectedQuestion, + new DailyQuestionReactionStatsViewModel( + exposureCount, + answeredCount, + rerolledCount, + unansweredCount + ), + revisions, + repository.findAnalyticsBaselineEffectiveFrom(), + refreshedAt() + ); + } + + public DailyQuestionOverviewViewModel getQuestionOverview(DailyQuestionOverviewQuery query) { + List allQuestions = repository.findQuestionOverview(); + String normalizedKeyword = query.keyword().toLowerCase(Locale.ROOT); + + List filteredQuestions = allQuestions.stream() + .filter(question -> matchesKeyword(question, normalizedKeyword)) + .filter(question -> query.interestCode() == null + || question.interestCode() == query.interestCode()) + .filter(question -> query.questionLevel() == null + || question.questionLevel() == query.questionLevel()) + .filter(question -> query.active() == null + || question.active() == query.active()) + .filter(question -> question.currentExposureCount() >= query.minimumCurrentExposureCount()) + .sorted(overviewComparator(query)) + .toList(); + + return new DailyQuestionOverviewViewModel( + filteredQuestions, + allQuestions.size(), + query, + repository.findAnalyticsBaselineEffectiveFrom(), + refreshedAt() + ); + } + + @Nullable + private DailyQuestionListItemViewModel selectQuestion( + List questions, + @Nullable Long questionId + ) { + if (questionId == null) { + return questions.isEmpty() ? null : questions.getFirst(); + } + + return questions.stream() + .filter(question -> question.questionId() == questionId) + .findFirst() + .orElse(null); + } + + private boolean matchesKeyword( + DailyQuestionOverviewRowViewModel question, + String normalizedKeyword + ) { + return normalizedKeyword.isEmpty() + || Long.toString(question.questionId()).contains(normalizedKeyword) + || question.questionText().toLowerCase(Locale.ROOT).contains(normalizedKeyword); + } + + private Comparator overviewComparator( + DailyQuestionOverviewQuery query + ) { + Comparator comparator = switch (query.sort()) { + case QUESTION_ID -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::questionId); + case CURRENT_REVISION_NO -> Comparator.comparingInt(DailyQuestionOverviewRowViewModel::currentRevisionNo); + case CURRENT_EXPOSURE_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::currentExposureCount); + case CURRENT_ANSWERED_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::currentAnsweredCount); + case CURRENT_ANSWER_RATE -> Comparator.comparingDouble(DailyQuestionOverviewRowViewModel::currentAnswerRate); + case CURRENT_REROLLED_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::currentRerolledCount); + case CURRENT_REROLL_RATE -> Comparator.comparingDouble(DailyQuestionOverviewRowViewModel::currentRerollRate); + case CURRENT_UNANSWERED_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::currentUnansweredCount); + case TOTAL_EXPOSURE_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::totalExposureCount); + case TOTAL_ANSWERED_COUNT -> Comparator.comparingLong(DailyQuestionOverviewRowViewModel::totalAnsweredCount); + case TOTAL_ANSWER_RATE -> Comparator.comparingDouble(DailyQuestionOverviewRowViewModel::totalAnswerRate); + }; + + if (query.direction() == DailyQuestionOverviewSortDirection.DESC) { + comparator = comparator.reversed(); + } + + return comparator + .thenComparing(Comparator.comparingLong( + DailyQuestionOverviewRowViewModel::currentExposureCount + ).reversed()) + .thenComparingLong(DailyQuestionOverviewRowViewModel::questionId); + } + + private String refreshedAt() { + return OffsetDateTime.now(SEOUL).format(FMT); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporter.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporter.java new file mode 100644 index 00000000..d9095549 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporter.java @@ -0,0 +1,93 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +@Component +public class DailyQuestionOverviewCsvExporter { + + private static final String UTF_8_BOM = "\uFEFF"; + private static final String LINE_SEPARATOR = "\r\n"; + private static final String HEADER = String.join(",", List.of( + "질문 ID", + "질문 문구", + "관심사 코드", + "관심사", + "레벨", + "상태", + "현재 Revision", + "현재 Revision 적용 시각", + "현재 Revision 노출", + "현재 Revision 답변", + "현재 Revision 답변율(%)", + "현재 Revision 교체", + "현재 Revision 교체율(%)", + "현재 Revision 미응답", + "전체 Revision 노출", + "전체 Revision 답변", + "전체 Revision 답변율(%)", + "전체 Revision 교체", + "전체 Revision 미응답" + )); + + public byte[] export(DailyQuestionOverviewViewModel viewModel) { + StringBuilder csv = new StringBuilder(UTF_8_BOM.length() + HEADER.length() + viewModel.rows().size() * 256); + csv.append(UTF_8_BOM).append(HEADER).append(LINE_SEPARATOR); + viewModel.rows().forEach(row -> appendRow(csv, row)); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private void appendRow(StringBuilder csv, DailyQuestionOverviewRowViewModel row) { + List cells = List.of( + Long.toString(row.questionId()), + textCell(row.questionText()), + textCell(row.interestCode() == null ? "" : row.interestCode().name()), + textCell(row.interestCode() == null ? "" : row.interestCode().displayNameKo()), + Integer.toString(row.questionLevel()), + textCell(row.active() ? "ACTIVE" : "INACTIVE"), + Integer.toString(row.currentRevisionNo()), + textCell(row.currentRevisionEffectiveFrom() == null + ? "" + : DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(row.currentRevisionEffectiveFrom())), + Long.toString(row.currentExposureCount()), + Long.toString(row.currentAnsweredCount()), + rateCell(row.currentExposureCount(), row.currentAnswerRatePercent()), + Long.toString(row.currentRerolledCount()), + rateCell(row.currentExposureCount(), row.currentRerollRatePercent()), + Long.toString(row.currentUnansweredCount()), + Long.toString(row.totalExposureCount()), + Long.toString(row.totalAnsweredCount()), + rateCell(row.totalExposureCount(), row.totalAnswerRatePercent()), + Long.toString(row.totalRerolledCount()), + Long.toString(row.totalUnansweredCount()) + ); + csv.append(String.join(",", cells)).append(LINE_SEPARATOR); + } + + private String rateCell(long exposureCount, double ratePercent) { + return exposureCount == 0L ? "" : String.format(Locale.ROOT, "%.1f", ratePercent); + } + + private String textCell(String value) { + String safeValue = neutralizeFormula(value == null ? "" : value); + return '"' + safeValue.replace("\"", "\"\"") + '"'; + } + + private String neutralizeFormula(String value) { + int firstContentIndex = 0; + while (firstContentIndex < value.length() && Character.isWhitespace(value.charAt(firstContentIndex))) { + firstContentIndex++; + } + if (firstContentIndex < value.length() + && "=+-@".indexOf(value.charAt(firstContentIndex)) >= 0) { + return "'" + value; + } + return value; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java index 2a76748f..e2cee631 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/controller/StatsController.java @@ -2,36 +2,56 @@ import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.QuestionStatsService; import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.helper.DailyQuestionOverviewCsvExporter; import com.devkor.ifive.nadab.domain.stats.application.helper.StatsPeriodResolver; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSort; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSortDirection; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.total.TotalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.withdrawal.WithdrawalStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.YearMonth; +import java.util.List; @Controller @RequiredArgsConstructor public class StatsController { + private static final MediaType CSV_MEDIA_TYPE = new MediaType("text", "csv", StandardCharsets.UTF_8); + private static final String QUESTION_OVERVIEW_CSV_FILENAME = "nadab_daily_question_stats.csv"; + private final DailyStatsService dailyStatsService; private final WeeklyStatsService weeklyStatsService; private final MonthlyStatsService monthlyStatsService; private final TotalStatsService totalStatsService; private final TypeStatsService typeStatsService; + private final QuestionStatsService questionStatsService; private final WithdrawalStatsService withdrawalStatsService; + private final DailyQuestionOverviewCsvExporter dailyQuestionOverviewCsvExporter; @GetMapping("/stats/daily") @@ -86,6 +106,100 @@ public String typeStats(Model model) { return "stats/type"; } + @GetMapping("/stats/question") + public String questionStats( + @RequestParam(required = false) Long questionId, + Model model + ) { + DailyQuestionStatsViewModel vm = questionStatsService.getQuestionStats(questionId); + model.addAttribute("vm", vm); + model.addAttribute("requestedQuestionId", questionId); + model.addAttribute("activeTab", "question"); + return "stats/question"; + } + + @GetMapping("/stats/question/overview") + public String questionOverview( + @RequestParam(defaultValue = "") String keyword, + @RequestParam(required = false) InterestCode interestCode, + @RequestParam(required = false) Integer questionLevel, + @RequestParam(required = false) Boolean active, + @RequestParam(defaultValue = "0") long minimumCurrentExposureCount, + @RequestParam(defaultValue = "CURRENT_EXPOSURE_COUNT") DailyQuestionOverviewSort sort, + @RequestParam(defaultValue = "DESC") DailyQuestionOverviewSortDirection direction, + Model model + ) { + DailyQuestionOverviewQuery query = resolveQuestionOverviewQuery( + keyword, + interestCode, + questionLevel, + active, + minimumCurrentExposureCount, + sort, + direction + ); + DailyQuestionOverviewViewModel vm = questionStatsService.getQuestionOverview(query); + model.addAttribute("vm", vm); + model.addAttribute("interestCodes", InterestCode.values()); + model.addAttribute("questionLevels", List.of(1, 2, 3, 4, 5)); + model.addAttribute("activeTab", "question"); + return "stats/question-overview"; + } + + @GetMapping(value = "/stats/question/overview.csv", produces = "text/csv;charset=UTF-8") + public ResponseEntity questionOverviewCsv( + @RequestParam(defaultValue = "") String keyword, + @RequestParam(required = false) InterestCode interestCode, + @RequestParam(required = false) Integer questionLevel, + @RequestParam(required = false) Boolean active, + @RequestParam(defaultValue = "0") long minimumCurrentExposureCount, + @RequestParam(defaultValue = "CURRENT_EXPOSURE_COUNT") DailyQuestionOverviewSort sort, + @RequestParam(defaultValue = "DESC") DailyQuestionOverviewSortDirection direction + ) { + DailyQuestionOverviewQuery query = resolveQuestionOverviewQuery( + keyword, + interestCode, + questionLevel, + active, + minimumCurrentExposureCount, + sort, + direction + ); + DailyQuestionOverviewViewModel vm = questionStatsService.getQuestionOverview(query); + return ResponseEntity.ok() + .contentType(CSV_MEDIA_TYPE) + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"%s\"".formatted(QUESTION_OVERVIEW_CSV_FILENAME) + ) + .body(dailyQuestionOverviewCsvExporter.export(vm)); + } + + private DailyQuestionOverviewQuery resolveQuestionOverviewQuery( + String keyword, + InterestCode interestCode, + Integer questionLevel, + Boolean active, + long minimumCurrentExposureCount, + DailyQuestionOverviewSort sort, + DailyQuestionOverviewSortDirection direction + ) { + if ((questionLevel != null && (questionLevel < 1 || questionLevel > 5)) + || minimumCurrentExposureCount < 0L) { + throw new BadRequestException(ErrorCode.VALIDATION_FAILED); + } + + return new DailyQuestionOverviewQuery( + keyword, + interestCode, + questionLevel, + active, + minimumCurrentExposureCount, + sort, + direction + ); + } + @GetMapping("/stats/withdrawal") public String withdrawalStats(Model model) { WithdrawalStatsViewModel vm = withdrawalStatsService.getWithdrawalStats(); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionListItemViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionListItemViewModel.java new file mode 100644 index 00000000..8c4d1843 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionListItemViewModel.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.OffsetDateTime; + +public record DailyQuestionListItemViewModel( + long questionId, + InterestCode interestCode, + String questionText, + int questionLevel, + int currentRevisionNo, + OffsetDateTime deletedAt +) { + + public boolean active() { + return deletedAt == null; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewQuery.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewQuery.java new file mode 100644 index 00000000..f31130ca --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewQuery.java @@ -0,0 +1,41 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import javax.annotation.Nullable; +import java.util.Objects; + +public record DailyQuestionOverviewQuery( + String keyword, + @Nullable InterestCode interestCode, + @Nullable Integer questionLevel, + @Nullable Boolean active, + long minimumCurrentExposureCount, + DailyQuestionOverviewSort sort, + DailyQuestionOverviewSortDirection direction +) { + + public DailyQuestionOverviewQuery { + keyword = keyword == null ? "" : keyword.trim(); + if (questionLevel != null && questionLevel < 1) { + throw new IllegalArgumentException("questionLevel must be positive"); + } + if (minimumCurrentExposureCount < 0L) { + throw new IllegalArgumentException("minimumCurrentExposureCount must not be negative"); + } + Objects.requireNonNull(sort, "sort must not be null"); + Objects.requireNonNull(direction, "direction must not be null"); + } + + public static DailyQuestionOverviewQuery defaults() { + return new DailyQuestionOverviewQuery( + "", + null, + null, + null, + 0L, + DailyQuestionOverviewSort.CURRENT_EXPOSURE_COUNT, + DailyQuestionOverviewSortDirection.DESC + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewRowViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewRowViewModel.java new file mode 100644 index 00000000..5ffae882 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewRowViewModel.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.OffsetDateTime; + +public record DailyQuestionOverviewRowViewModel( + long questionId, + InterestCode interestCode, + String questionText, + int questionLevel, + int currentRevisionNo, + OffsetDateTime deletedAt, + OffsetDateTime currentRevisionEffectiveFrom, + long currentExposureCount, + long currentAnsweredCount, + long currentRerolledCount, + long currentUnansweredCount, + long totalExposureCount, + long totalAnsweredCount, + long totalRerolledCount, + long totalUnansweredCount +) { + + public boolean active() { + return deletedAt == null; + } + + public double currentAnswerRate() { + return rate(currentAnsweredCount, currentExposureCount); + } + + public double currentAnswerRatePercent() { + return currentAnswerRate() * 100.0; + } + + public double currentRerollRate() { + return rate(currentRerolledCount, currentExposureCount); + } + + public double currentRerollRatePercent() { + return currentRerollRate() * 100.0; + } + + public double totalAnswerRate() { + return rate(totalAnsweredCount, totalExposureCount); + } + + public double totalAnswerRatePercent() { + return totalAnswerRate() * 100.0; + } + + private double rate(long count, long exposureCount) { + return exposureCount == 0L ? 0.0 : (double) count / exposureCount; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSort.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSort.java new file mode 100644 index 00000000..e0db7e9c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSort.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +public enum DailyQuestionOverviewSort { + QUESTION_ID, + CURRENT_REVISION_NO, + CURRENT_EXPOSURE_COUNT, + CURRENT_ANSWERED_COUNT, + CURRENT_ANSWER_RATE, + CURRENT_REROLLED_COUNT, + CURRENT_REROLL_RATE, + CURRENT_UNANSWERED_COUNT, + TOTAL_EXPOSURE_COUNT, + TOTAL_ANSWERED_COUNT, + TOTAL_ANSWER_RATE +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSortDirection.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSortDirection.java new file mode 100644 index 00000000..90726f6c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewSortDirection.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +public enum DailyQuestionOverviewSortDirection { + ASC, + DESC +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewViewModel.java new file mode 100644 index 00000000..dbfc1125 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionOverviewViewModel.java @@ -0,0 +1,18 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import javax.annotation.Nullable; +import java.time.OffsetDateTime; +import java.util.List; + +public record DailyQuestionOverviewViewModel( + List rows, + int totalQuestionCount, + DailyQuestionOverviewQuery query, + @Nullable OffsetDateTime baselineEffectiveFrom, + String refreshedAt +) { + + public int filteredQuestionCount() { + return rows.size(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionReactionStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionReactionStatsViewModel.java new file mode 100644 index 00000000..30e12e24 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionReactionStatsViewModel.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +public record DailyQuestionReactionStatsViewModel( + long exposureCount, + long answeredCount, + long rerolledCount, + long unansweredCount +) { + + public double answerRate() { + return exposureCount == 0L ? 0.0 : (double) answeredCount / exposureCount; + } + + public double answerRatePercent() { + return answerRate() * 100.0; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionRevisionStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionRevisionStatsViewModel.java new file mode 100644 index 00000000..adbd61e0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionRevisionStatsViewModel.java @@ -0,0 +1,36 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; + +import java.time.OffsetDateTime; + +public record DailyQuestionRevisionStatsViewModel( + long revisionId, + int revisionNo, + InterestCode interestCode, + String questionText, + int questionLevel, + String empathyGuide, + String hintGuide, + String leadingQuestionGuide, + OffsetDateTime deletedAt, + OffsetDateTime effectiveFrom, + String sourceMigration, + long exposureCount, + long answeredCount, + long rerolledCount, + long unansweredCount +) { + + public boolean active() { + return deletedAt == null; + } + + public double answerRate() { + return exposureCount == 0L ? 0.0 : (double) answeredCount / exposureCount; + } + + public double answerRatePercent() { + return answerRate() * 100.0; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionStatsViewModel.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionStatsViewModel.java new file mode 100644 index 00000000..4518c939 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/dto/question/DailyQuestionStatsViewModel.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.stats.core.dto.question; + +import javax.annotation.Nullable; +import java.time.OffsetDateTime; +import java.util.List; + +public record DailyQuestionStatsViewModel( + List questions, + @Nullable DailyQuestionListItemViewModel selectedQuestion, + DailyQuestionReactionStatsViewModel total, + List revisions, + @Nullable OffsetDateTime baselineEffectiveFrom, + String refreshedAt +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepository.java new file mode 100644 index 00000000..6d161bc2 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepository.java @@ -0,0 +1,149 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import javax.annotation.Nullable; +import java.time.OffsetDateTime; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class QuestionStatsRepository { + + private final EntityManager em; + + public List findQuestions() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel( + q.id, + interest.code, + q.questionText, + q.questionLevel, + q.currentRevisionNo, + q.deletedAt + ) + from DailyQuestion q + left join q.interest interest + order by q.id + """, DailyQuestionListItemViewModel.class) + .getResultList(); + } + + @Nullable + public OffsetDateTime findAnalyticsBaselineEffectiveFrom() { + return em.createQuery(""" + select min(revision.effectiveFrom) + from DailyQuestionRevision revision + where revision.revisionNo = 1 + """, OffsetDateTime.class) + .getSingleResult(); + } + + public List findRevisionStats(long questionId) { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel( + revision.id, + revision.revisionNo, + interest.code, + revision.questionText, + revision.questionLevel, + revision.empathyGuide, + revision.hintGuide, + revision.leadingQuestionGuide, + revision.deletedAt, + revision.effectiveFrom, + revision.sourceMigration, + count(exposure.id), + coalesce(sum(case when exposure.answeredAt is not null then 1 else 0 end), 0), + coalesce(sum(case when exposure.rerolledAt is not null then 1 else 0 end), 0), + coalesce(sum(case + when exposure.id is not null + and exposure.answeredAt is null + and exposure.rerolledAt is null + then 1 else 0 end), 0) + ) + from DailyQuestionRevision revision + left join revision.interest interest + left join DailyQuestionExposure exposure + on exposure.dailyQuestionRevision = revision + where revision.dailyQuestion.id = :questionId + group by + revision.id, + revision.revisionNo, + interest.code, + revision.questionText, + revision.questionLevel, + revision.empathyGuide, + revision.hintGuide, + revision.leadingQuestionGuide, + revision.deletedAt, + revision.effectiveFrom, + revision.sourceMigration + order by revision.revisionNo desc + """, DailyQuestionRevisionStatsViewModel.class) + .setParameter("questionId", questionId) + .getResultList(); + } + + public List findQuestionOverview() { + return em.createQuery(""" + select new com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel( + question.id, + interest.code, + question.questionText, + question.questionLevel, + question.currentRevisionNo, + question.deletedAt, + currentRevision.effectiveFrom, + coalesce(sum(case + when revision.id = currentRevision.id and exposure.id is not null + then 1 else 0 end), 0), + coalesce(sum(case + when revision.id = currentRevision.id and exposure.answeredAt is not null + then 1 else 0 end), 0), + coalesce(sum(case + when revision.id = currentRevision.id and exposure.rerolledAt is not null + then 1 else 0 end), 0), + coalesce(sum(case + when revision.id = currentRevision.id + and exposure.id is not null + and exposure.answeredAt is null + and exposure.rerolledAt is null + then 1 else 0 end), 0), + count(exposure.id), + coalesce(sum(case when exposure.answeredAt is not null then 1 else 0 end), 0), + coalesce(sum(case when exposure.rerolledAt is not null then 1 else 0 end), 0), + coalesce(sum(case + when exposure.id is not null + and exposure.answeredAt is null + and exposure.rerolledAt is null + then 1 else 0 end), 0) + ) + from DailyQuestion question + left join question.interest interest + join DailyQuestionRevision currentRevision + on currentRevision.dailyQuestion = question + and currentRevision.revisionNo = question.currentRevisionNo + join DailyQuestionRevision revision + on revision.dailyQuestion = question + left join DailyQuestionExposure exposure + on exposure.dailyQuestionRevision = revision + group by + question.id, + interest.code, + question.questionText, + question.questionLevel, + question.currentRevisionNo, + question.deletedAt, + currentRevision.id, + currentRevision.effectiveFrom + order by question.id + """, DailyQuestionOverviewRowViewModel.class) + .getResultList(); + } +} diff --git a/src/main/resources/db/migration/V20260825_1200__IS_create_daily_question_revisions_and_exposures.sql b/src/main/resources/db/migration/V20260825_1200__IS_create_daily_question_revisions_and_exposures.sql new file mode 100644 index 00000000..0aff4223 --- /dev/null +++ b/src/main/resources/db/migration/V20260825_1200__IS_create_daily_question_revisions_and_exposures.sql @@ -0,0 +1,99 @@ +ALTER TABLE daily_questions + ADD COLUMN current_revision_no INTEGER NOT NULL DEFAULT 1, + ADD CONSTRAINT ck_daily_questions_current_revision_no_positive + CHECK (current_revision_no > 0); + +CREATE TABLE daily_question_revisions ( + id BIGSERIAL PRIMARY KEY, + daily_question_id BIGINT NOT NULL REFERENCES daily_questions(id), + revision_no INTEGER NOT NULL, + interest_id INTEGER REFERENCES interests(id), + question_text VARCHAR(100) NOT NULL, + question_level INTEGER NOT NULL, + empathy_guide VARCHAR(100), + hint_guide VARCHAR(100), + leading_question_guide VARCHAR(100), + deleted_at TIMESTAMPTZ, + effective_from TIMESTAMPTZ NOT NULL, + source_migration VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT uq_daily_question_revisions_question_revision + UNIQUE (daily_question_id, revision_no), + CONSTRAINT uq_daily_question_revisions_id_question + UNIQUE (id, daily_question_id), + CONSTRAINT ck_daily_question_revisions_revision_no_positive + CHECK (revision_no > 0) +); + +INSERT INTO daily_question_revisions ( + daily_question_id, + revision_no, + interest_id, + question_text, + question_level, + empathy_guide, + hint_guide, + leading_question_guide, + deleted_at, + effective_from, + source_migration +) +SELECT + id, + current_revision_no, + interest_id, + question_text, + question_level, + empathy_guide, + hint_guide, + leading_question_guide, + deleted_at, + NOW(), + 'V20260825_1200' +FROM daily_questions; + +CREATE TABLE daily_question_exposures ( + id BIGSERIAL PRIMARY KEY, + user_daily_question_id BIGINT + REFERENCES user_daily_questions(id) ON DELETE SET NULL, + daily_question_revision_id BIGINT NOT NULL + REFERENCES daily_question_revisions(id), + assignment_date DATE NOT NULL, + sequence INTEGER NOT NULL, + source VARCHAR(30) NOT NULL, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + rerolled_at TIMESTAMPTZ, + answered_at TIMESTAMPTZ, + + CONSTRAINT uq_daily_question_exposures_assignment_sequence + UNIQUE (user_daily_question_id, sequence), + CONSTRAINT ck_daily_question_exposures_sequence_non_negative + CHECK (sequence >= 0), + CONSTRAINT ck_daily_question_exposures_source + CHECK (source IN ('INITIAL', 'REROLL')), + CONSTRAINT ck_daily_question_exposures_single_terminal_action + CHECK (rerolled_at IS NULL OR answered_at IS NULL), + CONSTRAINT ck_daily_question_exposures_rerolled_after_assignment + CHECK (rerolled_at IS NULL OR rerolled_at >= assigned_at), + CONSTRAINT ck_daily_question_exposures_answered_after_assignment + CHECK (answered_at IS NULL OR answered_at >= assigned_at) +); + +CREATE INDEX idx_daily_question_exposures_revision_date + ON daily_question_exposures (daily_question_revision_id, assignment_date); + +CREATE UNIQUE INDEX uq_daily_question_exposures_open_assignment + ON daily_question_exposures (user_daily_question_id) + WHERE user_daily_question_id IS NOT NULL + AND rerolled_at IS NULL + AND answered_at IS NULL; + +ALTER TABLE answer_entries + ADD COLUMN question_revision_id BIGINT, + ADD CONSTRAINT fk_answer_entries_question_revision + FOREIGN KEY (question_revision_id, question_id) + REFERENCES daily_question_revisions(id, daily_question_id); + +CREATE INDEX idx_answer_entries_question_revision_id + ON answer_entries (question_revision_id); diff --git a/src/main/resources/templates/stats/daily.html b/src/main/resources/templates/stats/daily.html index 59136b84..904f853e 100644 --- a/src/main/resources/templates/stats/daily.html +++ b/src/main/resources/templates/stats/daily.html @@ -282,6 +282,9 @@ 유형 + 질문 탈퇴 diff --git a/src/main/resources/templates/stats/monthly.html b/src/main/resources/templates/stats/monthly.html index eee8ab9a..f7734d98 100644 --- a/src/main/resources/templates/stats/monthly.html +++ b/src/main/resources/templates/stats/monthly.html @@ -272,6 +272,9 @@ 유형 + 질문 탈퇴 diff --git a/src/main/resources/templates/stats/question-overview.html b/src/main/resources/templates/stats/question-overview.html new file mode 100644 index 00000000..e7a93ce5 --- /dev/null +++ b/src/main/resources/templates/stats/question-overview.html @@ -0,0 +1,594 @@ + + + + + + NADAB - Question Overview + + + + + + + + + + + + +
+ 집계 시작 기준 + 질문 반응 추적 기능이 배포된 시점 이후(2026년 8월 27일 12:00 KST) 실제로 기록된 노출과 반응만 집계합니다. +
+ +
+
+
전체 질문
+
0
+
활성·비활성 질문 모두 포함
+
+
+
현재 검색 결과
+
0
+
현재 필터에 일치하는 질문 수
+
+
+
기본 비교 범위
+
CURRENT REVISION
+
수정 이후의 현재 질문 반응을 우선 비교
+
+
+ +
+
+

질문 필터 및 정렬

+

비율을 비교할 때는 최소 노출 수를 지정해 작은 표본이 상단에 노출되는 것을 줄일 수 있습니다.

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 초기화 + +
+
+
+ +
+ 읽는 법 + 파란 영역은 현재 Revision 반응이며 기본 비교 대상입니다. 전체 Revision 누적은 수정 전 데이터를 포함하는 참고값입니다. +
+ +
+
+
+

전체 질문 현황

+

행을 선택하면 해당 질문의 Revision별 상세 화면으로 이동합니다.

+
+
+
0 / 0 questions
+ CSV 다운로드 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID질문상태현재 Revision현재 Revision 반응전체 Revision 누적상세
조건에 맞는 질문이 없습니다. 필터나 최소 노출 수를 조정해 주세요.
#1 + 질문 문구 + +
+ 취향 + LEVEL 1 + ACTIVE +
+
+
REV 1
+
2026-08-26
+
00-0-000-보기 →
+
+
+ + + + + diff --git a/src/main/resources/templates/stats/question.html b/src/main/resources/templates/stats/question.html new file mode 100644 index 00000000..beb586d1 --- /dev/null +++ b/src/main/resources/templates/stats/question.html @@ -0,0 +1,858 @@ + + + + + + NADAB - Question Stats + + + + + + + + + + + + +
+
+

질문 찾기

+

질문 ID를 직접 입력하거나 현재 질문 문구로 목록을 좁힌 뒤 선택할 수 있습니다.

+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+ +
+ 집계 시작 기준 + 질문 반응 추적 기능이 배포된 시점 이후(2026년 8월 27일 12:00 KST) 실제로 생성된 할당과 교체만 집계합니다. 배포 전 할당·답변은 추정하거나 현재 수정 버전에 소급하지 않습니다. +
+ +
+
+ 조회할 질문이 없습니다. +
+
다른 질문 ID를 입력하거나 목록에서 질문을 선택해 주세요.
+
+ + +
+
+
+
QUESTION #1
+

선택한 질문

+
+ CURRENT REV 1 + LEVEL 1 + + 관심사 + + ACTIVE +
+
+
+
+ +
+
+
누적 노출
+
0
+
최초 할당과 교체 후 할당 포함
+
+
+
누적 답변
+
0
+
답변 저장이 완료된 노출
+
+
+
답변 비율
+
0.0%
+
누적 답변 / 누적 노출
+
+
+
교체
+
0
+
다른 질문으로 넘긴 노출
+
+
+
미응답
+
0
+
교체·답변이 아직 없는 노출
+
+
+ +
+
+

Revision별 반응 및 수정 이력

+

최신 revision부터 표시합니다. 각 스냅샷의 적용 시점과 Flyway 출처를 함께 확인할 수 있습니다.

+
+ +
+
Revision 이력이 없습니다.
+
질문 revision 데이터가 생성되었는지 확인해 주세요.
+
+ +
+
+
+
+

Revision 1

+ CURRENT + INACTIVE SNAPSHOT +
+
+ 적용 시각 + Flyway migration +
+
+
+

Revision 질문 문구

+ +
+
+
노출
+
0
+
+
+
답변
+
0
+
+
+
답변 비율
+
0.0%
+ +
+
+
교체
+
0
+
+
+
미응답
+
0
+
+
+ +
+ 전체 질문 스냅샷 보기 +
+
+
관심사 · 레벨
+
+ 관심사 · LEVEL 1 +
+
+
+
활성 상태
+
ACTIVE
+
+
+
공감 가이드
+
-
+
+
+
힌트 가이드
+
-
+
+
+
리딩 질문 가이드
+
-
+
+
+
Revision ID
+
1
+
+
+
+
+
+
+
+
+ +
+
+

이 페이지를 읽는 방법

+

질문 통계에서 사용하는 용어와 숫자의 의미입니다. 데이터 구조를 몰라도 아래 기준으로 해석할 수 있습니다.

+
+
+
+
+

Question ID (질문 ID)

+

하나의 질문을 계속 식별하는 고정 번호입니다. 질문 문구나 가이드가 수정되어도 Question ID는 바뀌지 않습니다.

+
+
+

Revision (수정 버전)

+

질문의 특정 시점 상태를 보존한 버전입니다. 문구·가이드·레벨·활성 상태 등이 바뀌면 새 Revision이 생기며, 이전 버전의 통계는 그대로 유지됩니다. CURRENT는 현재 사용 중인 최신 버전입니다.

+
+
+

노출

+

해당 Revision의 질문이 사용자에게 실제로 할당된 횟수입니다. 하루의 최초 할당뿐 아니라 질문 교체 후 새로 받은 할당도 포함합니다.

+
+
+

답변

+

노출된 질문에 대한 답변이 저장된 횟수입니다. 답변은 사용자가 보았던 당시 Revision에 연결됩니다.

+
+
+

교체

+

사용자가 답변하지 않고 다른 질문으로 넘긴 횟수입니다. 선호도를 살펴보는 참고 신호이지만, 교체가 곧 불호를 뜻하지는 않습니다.

+
+
+

미응답

+

아직 답변도 교체도 발생하지 않은 노출입니다. 오늘 진행 중인 질문처럼 나중에 답변 또는 교체로 바뀔 수 있는 건도 포함합니다.

+
+
+

답변 비율

+

답변 횟수 ÷ 노출 횟수로 계산합니다. 비율만 보지 말고 노출 표본 수를 함께 확인해야 합니다.

+
+
+

전체 누적과 Revision별 수치

+

상단 누적 수치는 선택한 Question ID의 모든 Revision을 합친 값입니다. 아래 카드는 수정 버전별 반응 차이를 비교하기 위한 값입니다.

+
+
+ +
+

예시로 이해하기

+

Revision 1이 100회 노출된 뒤 질문 문구를 수정하면 Revision 2가 시작됩니다. 이후 노출과 반응은 Revision 2에 별도로 쌓이고, 상단 전체 누적에는 Revision 1과 2가 모두 합산됩니다. 따라서 수정 효과를 볼 때는 Revision별 카드를 비교하세요.

+
+ +
+

해석할 때 주의할 점

+
    +
  • 교체에는 그날의 기분, 질문 난이도, 답변할 시간 부족 등 여러 이유가 있을 수 있으므로 불호로 단정하지 않습니다.
  • +
  • 미응답은 아직 진행 중인 상태를 포함하므로 시간이 지나면 답변 또는 교체로 바뀔 수 있습니다.
  • +
  • 노출 수가 적은 Revision의 비율은 쉽게 흔들리므로 충분한 표본이 쌓였는지 함께 확인합니다.
  • +
  • 질문 반응 추적 기능 배포 전 데이터는 소급하지 않았습니다. 기능 배포 이후 실제로 기록된 노출과 반응만 통계에 포함됩니다.
  • +
+
+
+
+ + + + + diff --git a/src/main/resources/templates/stats/total.html b/src/main/resources/templates/stats/total.html index 96e7038d..fbc99796 100644 --- a/src/main/resources/templates/stats/total.html +++ b/src/main/resources/templates/stats/total.html @@ -289,6 +289,9 @@ 유형 + 질문 탈퇴 diff --git a/src/main/resources/templates/stats/type.html b/src/main/resources/templates/stats/type.html index 4949b1ff..67757ee4 100644 --- a/src/main/resources/templates/stats/type.html +++ b/src/main/resources/templates/stats/type.html @@ -252,6 +252,9 @@ 유형 + 질문 탈퇴 diff --git a/src/main/resources/templates/stats/weekly.html b/src/main/resources/templates/stats/weekly.html index d575604d..29652756 100644 --- a/src/main/resources/templates/stats/weekly.html +++ b/src/main/resources/templates/stats/weekly.html @@ -259,6 +259,9 @@ 유형 + 질문 탈퇴 diff --git a/src/main/resources/templates/stats/withdrawal.html b/src/main/resources/templates/stats/withdrawal.html index ff99c806..1fc813bd 100644 --- a/src/main/resources/templates/stats/withdrawal.html +++ b/src/main/resources/templates/stats/withdrawal.html @@ -224,6 +224,7 @@ 주간 월간 유형 + 질문 탈퇴 전체 diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java index ba29125c..3004469e 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportServiceTest.java @@ -109,10 +109,11 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log( when(userRepository.findById(userId)).thenReturn(Optional.of(user)); when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question)); + UserDailyQuestion assignment = UserDailyQuestion.create(user, today, question); when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class))) - .thenReturn(Optional.of(UserDailyQuestion.create(user, today, question))); - when(dailyReportTxService.prepareDaily(user, question, "answer", false, null)) - .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId)); + .thenReturn(Optional.of(assignment)); + when(dailyReportTxService.prepareDaily(user, question, assignment, "answer", false, null)) + .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId, "revision question")); when(dailyReportModelSelector.select()).thenReturn(modelCandidate); when(reportGenerationLogRecorder.start( userId, @@ -122,7 +123,7 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log( LlmProvider.OPENAI, "gpt-5.6-luna" )).thenReturn(generationLogId); - when(dailyReportLlmClient.generate("question", answerEntry, modelCandidate)) + when(dailyReportLlmClient.generate("revision question", answerEntry, modelCandidate)) .thenReturn(new LlmGenerationResult<>(aiResult, new LlmTokenUsage(100L, 50L, 150L))); when(dailyReportTxService.confirmDailyAndReward( any(PrepareDailyResultDto.class), @@ -141,7 +142,7 @@ void generate_daily_report_records_token_usage_before_succeeding_generation_log( assertThat(response.content()).isEqualTo("message"); assertThat(response.balanceAfter()).isEqualTo(110L); verify(dailyReportModelSelector).select(); - verify(dailyReportLlmClient).generate("question", answerEntry, modelCandidate); + verify(dailyReportLlmClient).generate("revision question", answerEntry, modelCandidate); InOrder inOrder = inOrder(reportGenerationLogRecorder); inOrder.verify(reportGenerationLogRecorder).recordTokenUsage(generationLogId, 100L, 50L, 150L, null); @@ -164,10 +165,11 @@ void generate_daily_report_records_null_token_usage_when_usage_is_empty() { when(userRepository.findById(userId)).thenReturn(Optional.of(user)); when(dailyQuestionRepository.findByIdWithInterest(20L)).thenReturn(Optional.of(question)); + UserDailyQuestion assignment = UserDailyQuestion.create(user, today, question); when(userDailyQuestionRepository.findByUserIdAndDate(eq(userId), any(LocalDate.class))) - .thenReturn(Optional.of(UserDailyQuestion.create(user, today, question))); - when(dailyReportTxService.prepareDaily(user, question, "answer", false, null)) - .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId)); + .thenReturn(Optional.of(assignment)); + when(dailyReportTxService.prepareDaily(user, question, assignment, "answer", false, null)) + .thenReturn(new PrepareDailyResultDto(answerEntry, reportId, userId, "question")); when(dailyReportModelSelector.select()).thenReturn(modelCandidate); when(reportGenerationLogRecorder.start(any(), any(), any(), any(), any(), any())) .thenReturn(generationLogId); @@ -192,7 +194,6 @@ private User user(Long id) { private DailyQuestion dailyQuestion(Long id) { DailyQuestion question = mock(DailyQuestion.class); when(question.getId()).thenReturn(id); - when(question.getQuestionText()).thenReturn("question"); return question; } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxServiceTest.java index 5a326766..1be2ae39 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportTxServiceTest.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AiDailyReportResultDto; import com.devkor.ifive.nadab.domain.dailyreport.core.dto.PrepareDailyResultDto; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.Emotion; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionName; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; @@ -11,6 +12,9 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.service.AnswerEntryService; import com.devkor.ifive.nadab.domain.dailyreport.core.service.PendingDailyReportService; import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.service.DailyQuestionExposureService; import com.devkor.ifive.nadab.domain.user.core.entity.Interest; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.entity.User; @@ -62,6 +66,9 @@ class DailyReportTxServiceTest { @Mock private PendingDailyReportService pendingDailyReportService; + @Mock + private DailyQuestionExposureService dailyQuestionExposureService; + private DailyReportTxService service; @BeforeEach @@ -74,7 +81,93 @@ void setUp() { crystalLogRepository, eventPublisher, answerEntryService, - pendingDailyReportService + pendingDailyReportService, + dailyQuestionExposureService + ); + } + + @Test + void prepareDaily_links_answer_entry_to_open_exposure_revision() { + User user = mock(User.class); + DailyQuestion question = mock(DailyQuestion.class); + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + DailyQuestionRevision revision = mock(DailyQuestionRevision.class); + AnswerEntry answerEntry = mock(AnswerEntry.class); + DailyReport report = mock(DailyReport.class); + when(dailyQuestionExposureService.recordAnswer(assignment)).thenReturn(Optional.of(revision)); + when(answerEntry.getQuestionRevision()).thenReturn(revision); + when(revision.getQuestionText()).thenReturn("revision question"); + when(answerEntryService.getOrCreateTodayAnswerEntry( + user, + question, + revision, + "answer", + false, + null + )).thenReturn(answerEntry); + when(pendingDailyReportService.getOrCreatePendingDailyReport(answerEntry, false)).thenReturn(report); + when(report.getId()).thenReturn(10L); + + PrepareDailyResultDto result = service.prepareDaily( + user, + question, + assignment, + "answer", + false, + null + ); + + assertThat(result.entry()).isEqualTo(answerEntry); + assertThat(result.reportId()).isEqualTo(10L); + assertThat(result.questionText()).isEqualTo("revision question"); + verify(dailyQuestionExposureService).recordAnswer(assignment); + verify(answerEntryService).getOrCreateTodayAnswerEntry( + user, + question, + revision, + "answer", + false, + null + ); + } + + @Test + void prepareDaily_keeps_legacy_answer_without_revision() { + User user = mock(User.class); + DailyQuestion question = mock(DailyQuestion.class); + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + AnswerEntry answerEntry = mock(AnswerEntry.class); + DailyReport report = mock(DailyReport.class); + when(dailyQuestionExposureService.recordAnswer(assignment)).thenReturn(Optional.empty()); + when(question.getQuestionText()).thenReturn("legacy current question"); + when(answerEntryService.getOrCreateTodayAnswerEntry( + user, + question, + null, + "answer", + false, + null + )).thenReturn(answerEntry); + when(pendingDailyReportService.getOrCreatePendingDailyReport(answerEntry, false)).thenReturn(report); + when(report.getId()).thenReturn(20L); + + PrepareDailyResultDto result = service.prepareDaily( + user, + question, + assignment, + "answer", + false, + null + ); + + assertThat(result.questionText()).isEqualTo("legacy current question"); + verify(answerEntryService).getOrCreateTodayAnswerEntry( + user, + question, + null, + "answer", + false, + null ); } @@ -98,7 +191,7 @@ void confirmDailyAndReward_publishes_completed_event_with_answer_and_report_ids( when(crystalLogRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); service.confirmDailyAndReward( - new PrepareDailyResultDto(answerEntry, reportId, userId), + new PrepareDailyResultDto(answerEntry, reportId, userId, "question"), new AiDailyReportResultDto("report", emotionName.name()), null ); diff --git a/src/test/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandExposureServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandExposureServiceTest.java new file mode 100644 index 00000000..d454fa6b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/question/application/QuestionCommandExposureServiceTest.java @@ -0,0 +1,249 @@ +package com.devkor.ifive.nadab.domain.question.application; + +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; +import com.devkor.ifive.nadab.domain.question.application.helper.DailyQuestionSelector; +import com.devkor.ifive.nadab.domain.question.application.helper.QuestionLevelPolicy; +import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponse; +import com.devkor.ifive.nadab.domain.question.api.dto.response.DailyQuestionResponseV2; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.question.core.service.DailyQuestionExposureService; +import com.devkor.ifive.nadab.domain.user.core.entity.Interest; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserInterestRepository; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class QuestionCommandExposureServiceTest { + + private static final Long USER_ID = 1L; + private static final Long INTEREST_ID = 2L; + private static final LocalDate ASSIGNMENT_DATE = LocalDate.of(2026, 8, 26); + + @Mock + UserRepository userRepository; + + @Mock + UserDailyQuestionRepository userDailyQuestionRepository; + + @Mock + UserInterestRepository userInterestRepository; + + @Mock + AnswerEntryRepository answerEntryRepository; + + @Mock + QuestionLevelPolicy questionLevelPolicy; + + @Mock + DailyQuestionSelector dailyQuestionSelector; + + @Mock + DailyQuestionExposureService exposureService; + + QuestionCommandService service; + QuestionCommandServiceV2 serviceV2; + + @BeforeEach + void setUp() { + service = new QuestionCommandService( + userRepository, + userDailyQuestionRepository, + userInterestRepository, + answerEntryRepository, + questionLevelPolicy, + dailyQuestionSelector, + exposureService + ); + serviceV2 = new QuestionCommandServiceV2( + userRepository, + userDailyQuestionRepository, + userInterestRepository, + answerEntryRepository, + questionLevelPolicy, + dailyQuestionSelector, + exposureService + ); + } + + @Test + void v1_create_records_initial_assignment_exposure() { + User user = user(); + DailyQuestion question = mock(DailyQuestion.class); + stubInitialSelection(user); + when(questionLevelPolicy.levelOnlyFor(eq(user), any(OffsetDateTime.class))).thenReturn(1); + when(dailyQuestionSelector.pickFirst(USER_ID, INTEREST_ID, 1)).thenReturn(question); + + UserDailyQuestion assignment = service.createTodayQuestion(USER_ID, ASSIGNMENT_DATE); + + assertThat(assignment.getDailyQuestion()).isEqualTo(question); + verify(exposureService).recordInitialAssignment(assignment); + } + + @Test + void v2_create_records_initial_assignment_exposure() { + User user = user(); + DailyQuestion question = mock(DailyQuestion.class); + stubInitialSelection(user); + when(userDailyQuestionRepository.existsByUserId(USER_ID)).thenReturn(false); + when(questionLevelPolicy.levelOnlyForFirstTime(true)).thenReturn(1); + when(dailyQuestionSelector.pickFirst(USER_ID, INTEREST_ID, 1)).thenReturn(question); + + UserDailyQuestion assignment = serviceV2.createTodayQuestion(USER_ID, ASSIGNMENT_DATE); + + assertThat(assignment.getDailyQuestion()).isEqualTo(question); + verify(exposureService).recordInitialAssignment(assignment); + } + + @Test + void v1_reroll_records_exposure_after_assignment_change() { + User user = user(); + DailyQuestion currentQuestion = questionWithId(10L); + DailyQuestion newQuestion = responseQuestion(20L); + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + when(assignment.isRerollUsed()).thenReturn(false); + when(assignment.getUser()).thenReturn(user); + when(assignment.getDailyQuestion()).thenReturn(currentQuestion); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(USER_ID), any(LocalDate.class))) + .thenReturn(Optional.of(assignment)); + when(answerEntryRepository.existsByUserAndDate(eq(user), any(LocalDate.class))).thenReturn(false); + when(userInterestRepository.findInterestIdByUserId(USER_ID)).thenReturn(Optional.of(INTEREST_ID)); + when(questionLevelPolicy.levelOnlyFor(eq(user), any(OffsetDateTime.class))).thenReturn(1); + when(dailyQuestionSelector.pickReroll(USER_ID, INTEREST_ID, 10L, 1)).thenReturn(newQuestion); + + service.rerollTodayQuestion(USER_ID); + + InOrder inOrder = inOrder(assignment, exposureService); + inOrder.verify(assignment).rerollTo(newQuestion); + inOrder.verify(exposureService).recordReroll(assignment, newQuestion); + } + + @Test + void v2_reroll_records_exposure_after_assignment_change() { + User user = user(); + DailyQuestion currentQuestion = questionWithId(10L); + DailyQuestion newQuestion = responseQuestion(20L); + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + when(assignment.getRerollLeft()).thenReturn(5, 4); + when(assignment.getUser()).thenReturn(user); + when(assignment.getDailyQuestion()).thenReturn(currentQuestion); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(USER_ID), any(LocalDate.class))) + .thenReturn(Optional.of(assignment)); + when(answerEntryRepository.existsByUserAndDate(eq(user), any(LocalDate.class))).thenReturn(false); + when(userInterestRepository.findInterestIdByUserId(USER_ID)).thenReturn(Optional.of(INTEREST_ID)); + when(dailyQuestionSelector.pickReroll(USER_ID, INTEREST_ID, 10L, null)).thenReturn(newQuestion); + + serviceV2.rerollTodayQuestion(USER_ID); + + InOrder inOrder = inOrder(assignment, exposureService); + inOrder.verify(assignment).rerollTo(newQuestion); + inOrder.verify(exposureService).recordReroll(assignment, newQuestion); + } + + @Test + void v1_get_returns_revision_snapshot_for_tracked_assignment() { + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + DailyQuestion question = questionWithId(10L); + DailyQuestionRevision revision = revisionSnapshot(); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user())); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(USER_ID), any(LocalDate.class))) + .thenReturn(Optional.of(assignment)); + when(assignment.getDailyQuestion()).thenReturn(question); + when(assignment.isRerollUsed()).thenReturn(false); + when(exposureService.findLatestRevision(assignment)).thenReturn(Optional.of(revision)); + when(answerEntryRepository.existsActiveAnswer(USER_ID, 10L)).thenReturn(false); + + DailyQuestionResponse response = service.getOrCreateTodayQuestion(USER_ID); + + assertThat(response.questionText()).isEqualTo("revision question"); + assertThat(response.empathyGuide()).isEqualTo("revision empathy"); + assertThat(response.hintGuide()).isEqualTo("revision hint"); + assertThat(response.leadingQuestionGuide()).isEqualTo("revision leading"); + assertThat(response.interestCode()).isEqualTo(InterestCode.PREFERENCE.toString()); + } + + @Test + void v2_get_returns_revision_snapshot_for_tracked_assignment() { + UserDailyQuestion assignment = mock(UserDailyQuestion.class); + DailyQuestion question = questionWithId(10L); + DailyQuestionRevision revision = revisionSnapshot(); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user())); + when(userDailyQuestionRepository.findByUserIdAndDate(eq(USER_ID), any(LocalDate.class))) + .thenReturn(Optional.of(assignment)); + when(assignment.getDailyQuestion()).thenReturn(question); + when(assignment.getRerollLeft()).thenReturn(4); + when(exposureService.findLatestRevision(assignment)).thenReturn(Optional.of(revision)); + when(answerEntryRepository.existsActiveAnswer(USER_ID, 10L)).thenReturn(false); + + DailyQuestionResponseV2 response = serviceV2.getOrCreateTodayQuestion(USER_ID); + + assertThat(response.questionText()).isEqualTo("revision question"); + assertThat(response.empathyGuide()).isEqualTo("revision empathy"); + assertThat(response.hintGuide()).isEqualTo("revision hint"); + assertThat(response.leadingQuestionGuide()).isEqualTo("revision leading"); + assertThat(response.interestCode()).isEqualTo(InterestCode.PREFERENCE.toString()); + } + + private void stubInitialSelection(User user) { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userInterestRepository.findInterestIdByUserId(USER_ID)).thenReturn(Optional.of(INTEREST_ID)); + when(userDailyQuestionRepository.save(any(UserDailyQuestion.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + private User user() { + User user = User.createUser("test@test.com", "hashed_password"); + ReflectionTestUtils.setField(user, "id", USER_ID); + return user; + } + + private DailyQuestion questionWithId(Long id) { + DailyQuestion question = mock(DailyQuestion.class); + when(question.getId()).thenReturn(id); + return question; + } + + private DailyQuestion responseQuestion(Long id) { + Interest interest = mock(Interest.class); + when(interest.getCode()).thenReturn(InterestCode.PREFERENCE); + DailyQuestion question = mock(DailyQuestion.class); + when(question.getId()).thenReturn(id); + when(question.getInterest()).thenReturn(interest); + when(question.getQuestionText()).thenReturn("question"); + return question; + } + + private DailyQuestionRevision revisionSnapshot() { + Interest interest = mock(Interest.class); + when(interest.getCode()).thenReturn(InterestCode.PREFERENCE); + DailyQuestionRevision revision = mock(DailyQuestionRevision.class); + when(revision.getInterest()).thenReturn(interest); + when(revision.getQuestionText()).thenReturn("revision question"); + when(revision.getEmpathyGuide()).thenReturn("revision empathy"); + when(revision.getHintGuide()).thenReturn("revision hint"); + when(revision.getLeadingQuestionGuide()).thenReturn("revision leading"); + return revision; + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepositoryTest.java new file mode 100644 index 00000000..f502fe13 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionExposureRepositoryTest.java @@ -0,0 +1,153 @@ +package com.devkor.ifive.nadab.domain.question.core.repository; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposure; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposureSource; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +class DailyQuestionExposureRepositoryTest extends PostgresIntegrationTestSupport { + + private static final LocalDate ASSIGNMENT_DATE = LocalDate.of(2026, 8, 25); + + @Autowired + DailyQuestionExposureRepository exposureRepository; + + @Autowired + DailyQuestionRevisionRepository revisionRepository; + + @Autowired + TestEntityManager em; + + @Test + void supports_only_post_baseline_exposure_sources() { + assertThat(DailyQuestionExposureSource.values()) + .containsExactly( + DailyQuestionExposureSource.INITIAL, + DailyQuestionExposureSource.REROLL + ); + assertThat(exposureRepository.count()).isZero(); + } + + @Test + void persists_exposure_for_exact_question_revision() { + UserDailyQuestion assignment = assignment(); + DailyQuestionRevision revision = currentRevision(assignment.getDailyQuestion()); + + DailyQuestionExposure exposure = exposureRepository.saveAndFlush(DailyQuestionExposure.create( + assignment, + revision, + ASSIGNMENT_DATE, + 0, + DailyQuestionExposureSource.INITIAL + )); + em.clear(); + + DailyQuestionExposure found = exposureRepository.findById(exposure.getId()).orElseThrow(); + assertThat(found.getUserDailyQuestion().getId()).isEqualTo(assignment.getId()); + assertThat(found.getDailyQuestionRevision().getId()).isEqualTo(revision.getId()); + assertThat(found.getAssignmentDate()).isEqualTo(ASSIGNMENT_DATE); + assertThat(found.getSequence()).isZero(); + assertThat(found.getSource()).isEqualTo(DailyQuestionExposureSource.INITIAL); + assertThat(found.getAssignedAt()).isNotNull(); + assertThat(found.getRerolledAt()).isNull(); + assertThat(found.getAnsweredAt()).isNull(); + } + + @Test + void rejects_two_open_exposures_for_same_assignment() { + UserDailyQuestion assignment = assignment(); + DailyQuestionRevision revision = currentRevision(assignment.getDailyQuestion()); + exposureRepository.saveAndFlush(DailyQuestionExposure.create( + assignment, + revision, + ASSIGNMENT_DATE, + 0, + DailyQuestionExposureSource.INITIAL + )); + + DailyQuestionExposure duplicateOpenExposure = DailyQuestionExposure.create( + assignment, + revision, + ASSIGNMENT_DATE, + 1, + DailyQuestionExposureSource.REROLL + ); + + assertThatThrownBy(() -> exposureRepository.saveAndFlush(duplicateOpenExposure)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void rejects_exposure_marked_as_both_rerolled_and_answered() { + UserDailyQuestion assignment = assignment(); + DailyQuestionExposure exposure = DailyQuestionExposure.create( + assignment, + currentRevision(assignment.getDailyQuestion()), + ASSIGNMENT_DATE, + 0, + DailyQuestionExposureSource.INITIAL + ); + OffsetDateTime terminalAt = exposure.getAssignedAt().plusMinutes(1); + ReflectionTestUtils.setField(exposure, "rerolledAt", terminalAt); + ReflectionTestUtils.setField(exposure, "answeredAt", terminalAt); + + assertThatThrownBy(() -> exposureRepository.saveAndFlush(exposure)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void keeps_anonymized_exposure_when_user_is_hard_deleted() { + UserDailyQuestion assignment = assignment(); + Long userId = assignment.getUser().getId(); + DailyQuestionExposure exposure = exposureRepository.saveAndFlush(DailyQuestionExposure.create( + assignment, + currentRevision(assignment.getDailyQuestion()), + ASSIGNMENT_DATE, + 0, + DailyQuestionExposureSource.INITIAL + )); + + em.getEntityManager().createNativeQuery("DELETE FROM users WHERE id = :userId") + .setParameter("userId", userId) + .executeUpdate(); + em.flush(); + em.clear(); + + DailyQuestionExposure found = exposureRepository.findById(exposure.getId()).orElseThrow(); + assertThat(found.getUserDailyQuestion()).isNull(); + assertThat(found.getDailyQuestionRevision()).isNotNull(); + } + + private UserDailyQuestion assignment() { + User user = new UserBuilder(em).build(); + DailyQuestion question = em.find(DailyQuestion.class, 1L); + UserDailyQuestion assignment = UserDailyQuestion.create(user, ASSIGNMENT_DATE, question); + em.persistAndFlush(assignment); + return assignment; + } + + private DailyQuestionRevision currentRevision(DailyQuestion question) { + return revisionRepository + .findByDailyQuestion_IdAndRevisionNo(question.getId(), question.getCurrentRevisionNo()) + .orElseThrow(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepositoryTest.java new file mode 100644 index 00000000..baccb019 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/question/core/repository/DailyQuestionRevisionRepositoryTest.java @@ -0,0 +1,150 @@ +package com.devkor.ifive.nadab.domain.question.core.repository; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryRepository; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@ActiveProfiles("test") +class DailyQuestionRevisionRepositoryTest extends PostgresIntegrationTestSupport { + + private static final String BASELINE_MIGRATION = "V20260825_1200"; + + @Autowired + DailyQuestionRevisionRepository revisionRepository; + + @Autowired + AnswerEntryRepository answerEntryRepository; + + @Autowired + TestEntityManager em; + + @Autowired + JdbcTemplate jdbcTemplate; + + @Test + void baseline_revision_matches_every_daily_question() { + Long questionCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM daily_questions", + Long.class + ); + Long baselineCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM daily_question_revisions WHERE source_migration = ?", + Long.class, + BASELINE_MIGRATION + ); + Long mismatchCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM daily_questions q + LEFT JOIN daily_question_revisions r + ON r.daily_question_id = q.id + AND r.revision_no = q.current_revision_no + WHERE r.id IS NULL + OR r.interest_id IS DISTINCT FROM q.interest_id + OR r.question_text IS DISTINCT FROM q.question_text + OR r.question_level IS DISTINCT FROM q.question_level + OR r.empathy_guide IS DISTINCT FROM q.empathy_guide + OR r.hint_guide IS DISTINCT FROM q.hint_guide + OR r.leading_question_guide IS DISTINCT FROM q.leading_question_guide + OR r.deleted_at IS DISTINCT FROM q.deleted_at + """, Long.class); + + assertThat(questionCount).isPositive(); + assertThat(baselineCount).isEqualTo(questionCount); + assertThat(mismatchCount).isZero(); + } + + @Test + void finds_revision_by_logical_question_id_and_revision_number() { + DailyQuestion question = em.find(DailyQuestion.class, 1L); + + DailyQuestionRevision revision = revisionRepository + .findByDailyQuestion_IdAndRevisionNo(question.getId(), question.getCurrentRevisionNo()) + .orElseThrow(); + + assertThat(revision.getDailyQuestion().getId()).isEqualTo(question.getId()); + assertThat(revision.getRevisionNo()).isEqualTo(1); + assertThat(revision.getQuestionText()).isEqualTo(question.getQuestionText()); + assertThat(revision.getQuestionLevel()).isEqualTo(question.getQuestionLevel()); + assertThat(revision.getSourceMigration()).isEqualTo(BASELINE_MIGRATION); + assertThat(revision.getEffectiveFrom()).isNotNull(); + } + + @Test + void legacy_answer_entry_can_remain_without_revision() { + User user = new UserBuilder(em).build(); + DailyQuestion question = em.find(DailyQuestion.class, 1L); + AnswerEntry answerEntry = AnswerEntry.create( + user, + question, + "legacy answer", + LocalDate.of(2026, 8, 24), + null + ); + + em.persistAndFlush(answerEntry); + em.clear(); + + AnswerEntry found = em.find(AnswerEntry.class, answerEntry.getId()); + assertThat(found.getQuestionRevision()).isNull(); + } + + @Test + void answer_entry_persists_matching_question_revision() { + User user = new UserBuilder(em).build(); + DailyQuestion question = em.find(DailyQuestion.class, 1L); + DailyQuestionRevision revision = revisionRepository + .findByDailyQuestion_IdAndRevisionNo(question.getId(), question.getCurrentRevisionNo()) + .orElseThrow(); + AnswerEntry answerEntry = AnswerEntry.create( + user, + question, + revision, + "answer", + LocalDate.of(2026, 8, 26), + null + ); + + em.persistAndFlush(answerEntry); + em.clear(); + + AnswerEntry found = em.find(AnswerEntry.class, answerEntry.getId()); + assertThat(found.getQuestionRevision().getId()).isEqualTo(revision.getId()); + } + + @Test + void rejects_answer_entry_revision_from_different_logical_question() { + User user = new UserBuilder(em).build(); + DailyQuestion answerQuestion = em.find(DailyQuestion.class, 2L); + DailyQuestionRevision differentQuestionRevision = revisionRepository + .findByDailyQuestion_IdAndRevisionNo(1L, 1) + .orElseThrow(); + AnswerEntry answerEntry = AnswerEntry.create( + user, + answerQuestion, + differentQuestionRevision, + "answer", + LocalDate.of(2026, 8, 25), + null + ); + + assertThatThrownBy(() -> answerEntryRepository.saveAndFlush(answerEntry)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureServiceTest.java new file mode 100644 index 00000000..39ef627f --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/question/core/service/DailyQuestionExposureServiceTest.java @@ -0,0 +1,136 @@ +package com.devkor.ifive.nadab.domain.question.core.service; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposure; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposureSource; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionExposureRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@Import(DailyQuestionExposureService.class) +class DailyQuestionExposureServiceTest extends PostgresIntegrationTestSupport { + + private static final LocalDate ASSIGNMENT_DATE = LocalDate.of(2026, 8, 26); + + @Autowired + DailyQuestionExposureService exposureService; + + @Autowired + DailyQuestionExposureRepository exposureRepository; + + @Autowired + TestEntityManager em; + + @Test + void records_initial_assignment_with_current_revision() { + UserDailyQuestion assignment = assignment(1L); + + exposureService.recordInitialAssignment(assignment); + em.flush(); + em.clear(); + + DailyQuestionExposure exposure = exposureRepository.findAll().getFirst(); + assertThat(exposure.getUserDailyQuestion().getId()).isEqualTo(assignment.getId()); + assertThat(exposure.getDailyQuestionRevision().getDailyQuestion().getId()).isEqualTo(1L); + assertThat(exposure.getDailyQuestionRevision().getRevisionNo()).isEqualTo(1); + assertThat(exposure.getSequence()).isZero(); + assertThat(exposure.getSource()).isEqualTo(DailyQuestionExposureSource.INITIAL); + assertThat(exposure.isOpen()).isTrue(); + } + + @Test + void reroll_closes_open_exposure_and_creates_next_revision_exposure() { + UserDailyQuestion assignment = assignment(1L); + exposureService.recordInitialAssignment(assignment); + DailyQuestion newQuestion = em.find(DailyQuestion.class, 2L); + + assignment.rerollTo(newQuestion); + exposureService.recordReroll(assignment, newQuestion); + em.flush(); + em.clear(); + + List exposures = exposuresBySequence(); + assertThat(exposures).hasSize(2); + assertThat(exposures.get(0).getRerolledAt()).isNotNull(); + assertThat(exposures.get(0).isOpen()).isFalse(); + assertThat(exposures.get(1).getSequence()).isEqualTo(1); + assertThat(exposures.get(1).getSource()).isEqualTo(DailyQuestionExposureSource.REROLL); + assertThat(exposures.get(1).getDailyQuestionRevision().getDailyQuestion().getId()).isEqualTo(2L); + assertThat(exposures.get(1).isOpen()).isTrue(); + } + + @Test + void answer_closes_open_exposure_and_returns_its_revision() { + UserDailyQuestion assignment = assignment(1L); + exposureService.recordInitialAssignment(assignment); + + Optional revision = exposureService.recordAnswer(assignment); + em.flush(); + em.clear(); + + DailyQuestionExposure exposure = exposureRepository.findAll().getFirst(); + assertThat(revision).isPresent(); + assertThat(revision.orElseThrow().getId()).isEqualTo(exposure.getDailyQuestionRevision().getId()); + assertThat(exposure.getAnsweredAt()).isNotNull(); + assertThat(exposure.isOpen()).isFalse(); + } + + @Test + void legacy_assignment_answer_does_not_create_or_adopt_exposure() { + UserDailyQuestion assignment = assignment(1L); + + Optional revision = exposureService.recordAnswer(assignment); + + assertThat(revision).isEmpty(); + assertThat(exposureRepository.count()).isZero(); + } + + @Test + void legacy_assignment_reroll_starts_tracking_from_reroll_event() { + UserDailyQuestion assignment = assignment(1L); + DailyQuestion newQuestion = em.find(DailyQuestion.class, 2L); + + assignment.rerollTo(newQuestion); + exposureService.recordReroll(assignment, newQuestion); + em.flush(); + em.clear(); + + DailyQuestionExposure exposure = exposureRepository.findAll().getFirst(); + assertThat(exposure.getSequence()).isZero(); + assertThat(exposure.getSource()).isEqualTo(DailyQuestionExposureSource.REROLL); + assertThat(exposure.getDailyQuestionRevision().getDailyQuestion().getId()).isEqualTo(2L); + assertThat(exposure.isOpen()).isTrue(); + } + + private UserDailyQuestion assignment(Long questionId) { + User user = new UserBuilder(em).build(); + DailyQuestion question = em.find(DailyQuestion.class, questionId); + UserDailyQuestion assignment = UserDailyQuestion.create(user, ASSIGNMENT_DATE, question); + em.persistAndFlush(assignment); + return assignment; + } + + private List exposuresBySequence() { + return exposureRepository.findAll().stream() + .sorted(Comparator.comparingInt(DailyQuestionExposure::getSequence)) + .toList(); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsServiceTest.java new file mode 100644 index 00000000..0f648a8b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/QuestionStatsServiceTest.java @@ -0,0 +1,272 @@ +package com.devkor.ifive.nadab.domain.stats.application; + +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSort; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSortDirection; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.repository.QuestionStatsRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class QuestionStatsServiceTest { + + private static final OffsetDateTime EFFECTIVE_FROM = + OffsetDateTime.parse("2026-08-26T00:00:00+09:00"); + private static final OffsetDateTime ANALYTICS_BASELINE = + OffsetDateTime.parse("2026-08-25T12:00:00+09:00"); + + @Test + void defaults_to_first_question_and_sums_all_revision_counts() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + DailyQuestionListItemViewModel firstQuestion = question(1L); + List questions = List.of(firstQuestion, question(2L)); + List revisions = List.of( + revision(2, 2L, 1L, 0L, 1L), + revision(1, 3L, 1L, 1L, 1L) + ); + when(repository.findQuestions()).thenReturn(questions); + when(repository.findRevisionStats(1L)).thenReturn(revisions); + when(repository.findAnalyticsBaselineEffectiveFrom()).thenReturn(ANALYTICS_BASELINE); + + DailyQuestionStatsViewModel stats = service.getQuestionStats(null); + + assertThat(stats.selectedQuestion()).isEqualTo(firstQuestion); + assertThat(stats.revisions()).isEqualTo(revisions); + assertThat(stats.total().exposureCount()).isEqualTo(5L); + assertThat(stats.total().answeredCount()).isEqualTo(2L); + assertThat(stats.total().rerolledCount()).isEqualTo(1L); + assertThat(stats.total().unansweredCount()).isEqualTo(2L); + assertThat(stats.total().answerRate()).isEqualTo(0.4); + assertThat(stats.baselineEffectiveFrom()).isEqualTo(ANALYTICS_BASELINE); + assertThat(stats.refreshedAt()).isNotBlank(); + verify(repository).findRevisionStats(1L); + } + + @Test + void selects_requested_question() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + DailyQuestionListItemViewModel selectedQuestion = question(2L); + when(repository.findQuestions()).thenReturn(List.of(question(1L), selectedQuestion)); + when(repository.findRevisionStats(2L)).thenReturn(List.of(revision(1, 1L, 1L, 0L, 0L))); + + DailyQuestionStatsViewModel stats = service.getQuestionStats(2L); + + assertThat(stats.selectedQuestion()).isEqualTo(selectedQuestion); + assertThat(stats.total().answerRate()).isEqualTo(1.0); + verify(repository).findRevisionStats(2L); + } + + @Test + void returns_empty_detail_when_requested_question_does_not_exist() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + when(repository.findQuestions()).thenReturn(List.of(question(1L))); + + DailyQuestionStatsViewModel stats = service.getQuestionStats(999L); + + assertThat(stats.selectedQuestion()).isNull(); + assertThat(stats.revisions()).isEmpty(); + assertThat(stats.total().exposureCount()).isZero(); + verify(repository, never()).findRevisionStats(999L); + } + + @Test + void overview_defaults_to_current_exposure_descending_with_stable_id_tiebreaker() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + DailyQuestionOverviewRowViewModel question1 = overviewQuestion( + 1L, InterestCode.PREFERENCE, "질문 1", 1, true, + 2L, 1L, 0L, 1L, + 7L, 3L, 2L, 2L + ); + DailyQuestionOverviewRowViewModel question2 = overviewQuestion( + 2L, InterestCode.EMOTION, "질문 2", 2, true, + 5L, 2L, 2L, 1L, + 5L, 2L, 2L, 1L + ); + DailyQuestionOverviewRowViewModel question3 = overviewQuestion( + 3L, InterestCode.RELATIONSHIP, "질문 3", 2, false, + 5L, 1L, 3L, 1L, + 10L, 4L, 4L, 2L + ); + when(repository.findQuestionOverview()).thenReturn(List.of(question1, question3, question2)); + when(repository.findAnalyticsBaselineEffectiveFrom()).thenReturn(ANALYTICS_BASELINE); + + DailyQuestionOverviewViewModel overview = service.getQuestionOverview( + DailyQuestionOverviewQuery.defaults() + ); + + assertThat(overview.rows()) + .extracting(DailyQuestionOverviewRowViewModel::questionId) + .containsExactly(2L, 3L, 1L); + assertThat(overview.totalQuestionCount()).isEqualTo(3); + assertThat(overview.filteredQuestionCount()).isEqualTo(3); + assertThat(overview.rows().getLast().totalExposureCount()).isEqualTo(7L); + assertThat(overview.baselineEffectiveFrom()).isEqualTo(ANALYTICS_BASELINE); + assertThat(overview.refreshedAt()).isNotBlank(); + } + + @Test + void overview_filters_by_keyword_metadata_and_minimum_current_exposure() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + DailyQuestionOverviewRowViewModel matchingQuestion = overviewQuestion( + 12L, InterestCode.EMOTION, "비 올 때 듣는 노래", 2, false, + 8L, 3L, 4L, 1L, + 12L, 5L, 5L, 2L + ); + when(repository.findQuestionOverview()).thenReturn(List.of( + overviewQuestion( + 1L, InterestCode.PREFERENCE, "자주 듣는 노래", 1, true, + 10L, 6L, 2L, 2L, + 10L, 6L, 2L, 2L + ), + matchingQuestion, + overviewQuestion( + 13L, InterestCode.EMOTION, "비 오는 날의 질문", 2, false, + 2L, 1L, 1L, 0L, + 2L, 1L, 1L, 0L + ) + )); + DailyQuestionOverviewQuery query = new DailyQuestionOverviewQuery( + " 비 ", + InterestCode.EMOTION, + 2, + false, + 5L, + DailyQuestionOverviewSort.QUESTION_ID, + DailyQuestionOverviewSortDirection.ASC + ); + + DailyQuestionOverviewViewModel overview = service.getQuestionOverview(query); + + assertThat(overview.rows()).containsExactly(matchingQuestion); + assertThat(overview.totalQuestionCount()).isEqualTo(3); + assertThat(overview.filteredQuestionCount()).isEqualTo(1); + assertThat(overview.query().keyword()).isEqualTo("비"); + } + + @Test + void overview_can_rank_reroll_rate_after_excluding_small_samples() { + QuestionStatsRepository repository = mock(QuestionStatsRepository.class); + QuestionStatsService service = new QuestionStatsService(repository); + DailyQuestionOverviewRowViewModel question1 = overviewQuestion( + 1L, InterestCode.PREFERENCE, "질문 1", 1, true, + 100L, 70L, 20L, 10L, + 100L, 70L, 20L, 10L + ); + DailyQuestionOverviewRowViewModel question2 = overviewQuestion( + 2L, InterestCode.PREFERENCE, "질문 2", 1, true, + 10L, 4L, 5L, 1L, + 10L, 4L, 5L, 1L + ); + DailyQuestionOverviewRowViewModel smallSample = overviewQuestion( + 3L, InterestCode.PREFERENCE, "질문 3", 1, true, + 2L, 0L, 2L, 0L, + 2L, 0L, 2L, 0L + ); + when(repository.findQuestionOverview()).thenReturn(List.of(question1, question2, smallSample)); + DailyQuestionOverviewQuery query = new DailyQuestionOverviewQuery( + "", + null, + null, + null, + 10L, + DailyQuestionOverviewSort.CURRENT_REROLL_RATE, + DailyQuestionOverviewSortDirection.DESC + ); + + DailyQuestionOverviewViewModel overview = service.getQuestionOverview(query); + + assertThat(overview.rows()) + .extracting(DailyQuestionOverviewRowViewModel::questionId) + .containsExactly(2L, 1L); + assertThat(overview.rows().getFirst().currentRerollRate()).isEqualTo(0.5); + } + + private DailyQuestionListItemViewModel question(long questionId) { + return new DailyQuestionListItemViewModel( + questionId, + InterestCode.PREFERENCE, + "질문 " + questionId, + 1, + 1, + null + ); + } + + private DailyQuestionRevisionStatsViewModel revision( + int revisionNo, + long exposureCount, + long answeredCount, + long rerolledCount, + long unansweredCount + ) { + return new DailyQuestionRevisionStatsViewModel( + (long) revisionNo, + revisionNo, + InterestCode.PREFERENCE, + "revision " + revisionNo, + 1, + null, + null, + null, + null, + EFFECTIVE_FROM, + "V_TEST_" + revisionNo, + exposureCount, + answeredCount, + rerolledCount, + unansweredCount + ); + } + + private DailyQuestionOverviewRowViewModel overviewQuestion( + long questionId, + InterestCode interestCode, + String questionText, + int questionLevel, + boolean active, + long currentExposureCount, + long currentAnsweredCount, + long currentRerolledCount, + long currentUnansweredCount, + long totalExposureCount, + long totalAnsweredCount, + long totalRerolledCount, + long totalUnansweredCount + ) { + return new DailyQuestionOverviewRowViewModel( + questionId, + interestCode, + questionText, + questionLevel, + 2, + active ? null : EFFECTIVE_FROM, + EFFECTIVE_FROM, + currentExposureCount, + currentAnsweredCount, + currentRerolledCount, + currentUnansweredCount, + totalExposureCount, + totalAnsweredCount, + totalRerolledCount, + totalUnansweredCount + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporterTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporterTest.java new file mode 100644 index 00000000..67e4f9ee --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/application/helper/DailyQuestionOverviewCsvExporterTest.java @@ -0,0 +1,78 @@ +package com.devkor.ifive.nadab.domain.stats.application.helper; + +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DailyQuestionOverviewCsvExporterTest { + + private final DailyQuestionOverviewCsvExporter exporter = new DailyQuestionOverviewCsvExporter(); + + @Test + void export_writes_excel_compatible_csv_and_neutralizes_formula_text() { + DailyQuestionOverviewRowViewModel activeQuestion = new DailyQuestionOverviewRowViewModel( + 42L, + InterestCode.PREFERENCE, + "=SUM(A1:A2), \"질문\"", + 2, + 2, + null, + OffsetDateTime.parse("2026-08-26T12:00:00+09:00"), + 10L, + 4L, + 3L, + 3L, + 15L, + 6L, + 4L, + 5L + ); + DailyQuestionOverviewRowViewModel inactiveQuestionWithoutExposure = + new DailyQuestionOverviewRowViewModel( + 43L, + InterestCode.EMOTION, + "일반 질문", + 1, + 1, + OffsetDateTime.parse("2026-08-27T09:00:00+09:00"), + null, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L, + 0L + ); + DailyQuestionOverviewViewModel viewModel = new DailyQuestionOverviewViewModel( + List.of(activeQuestion, inactiveQuestionWithoutExposure), + 2, + DailyQuestionOverviewQuery.defaults(), + OffsetDateTime.parse("2026-08-25T12:00:00+09:00"), + "2026-08-27 10:00:00" + ); + + byte[] csvBytes = exporter.export(viewModel); + + assertThat(csvBytes).startsWith((byte) 0xEF, (byte) 0xBB, (byte) 0xBF); + assertThat(new String(csvBytes, StandardCharsets.UTF_8)).isEqualTo( + "\uFEFF질문 ID,질문 문구,관심사 코드,관심사,레벨,상태,현재 Revision,현재 Revision 적용 시각," + + "현재 Revision 노출,현재 Revision 답변,현재 Revision 답변율(%),현재 Revision 교체," + + "현재 Revision 교체율(%),현재 Revision 미응답,전체 Revision 노출,전체 Revision 답변," + + "전체 Revision 답변율(%),전체 Revision 교체,전체 Revision 미응답\r\n" + + "42,\"'=SUM(A1:A2), \"\"질문\"\"\",\"PREFERENCE\",\"취향\",2,\"ACTIVE\",2," + + "\"2026-08-26T12:00:00+09:00\",10,4,40.0,3,30.0,3,15,6,40.0,4,5\r\n" + + "43,\"일반 질문\",\"EMOTION\",\"감정\",1,\"INACTIVE\",1,\"\"," + + "0,0,,0,,0,0,0,,0,0\r\n" + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java index 4a6a861e..4cb55e98 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/controller/StatsControllerTemplateTest.java @@ -3,42 +3,65 @@ import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthInterceptor; import com.devkor.ifive.nadab.domain.stats.application.DailyStatsService; import com.devkor.ifive.nadab.domain.stats.application.MonthlyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.QuestionStatsService; import com.devkor.ifive.nadab.domain.stats.application.TotalStatsService; import com.devkor.ifive.nadab.domain.stats.application.TypeStatsService; import com.devkor.ifive.nadab.domain.stats.application.WithdrawalStatsService; import com.devkor.ifive.nadab.domain.stats.application.WeeklyStatsService; +import com.devkor.ifive.nadab.domain.stats.application.helper.DailyQuestionOverviewCsvExporter; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.daily.DailyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.monthly.MonthlyStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.peak.PeakStatViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewQuery; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSort; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewSortDirection; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionReactionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeReportInterestSeriesViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.type.TypeStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyPeriodStatsViewModel; import com.devkor.ifive.nadab.domain.stats.core.dto.weekly.WeeklyStatsViewModel; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.global.security.filter.JwtAuthenticationFilter; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; +import java.time.OffsetDateTime; import java.time.YearMonth; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.mockito.Mockito.verify; 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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(StatsController.class) @AutoConfigureMockMvc(addFilters = false) +@Import(DailyQuestionOverviewCsvExporter.class) class StatsControllerTemplateTest { + private static final OffsetDateTime ANALYTICS_BASELINE = + OffsetDateTime.parse("2026-08-25T12:00:00+09:00"); + @Autowired private MockMvc mockMvc; @@ -53,6 +76,8 @@ class StatsControllerTemplateTest { @MockitoBean private TypeStatsService typeStatsService; @MockitoBean + private QuestionStatsService questionStatsService; + @MockitoBean private WithdrawalStatsService withdrawalStatsService; @MockitoBean private AdminPageAuthInterceptor adminPageAuthInterceptor; @@ -191,6 +216,302 @@ void statsPages_reject_invalid_or_future_period_parameters() throws Exception { .andExpect(status().isBadRequest()); mockMvc.perform(get("/stats/monthly").param("month", "2026-13")) .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question").param("questionId", "not-a-number")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question/overview").param("questionLevel", "0")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question/overview").param("questionLevel", "6")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question/overview").param("minimumCurrentExposureCount", "-1")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question/overview").param("sort", "INVALID")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/stats/question/overview.csv").param("questionLevel", "6")) + .andExpect(status().isBadRequest()); + } + + @Test + void questionOverview_renders_filters_sortable_table_and_detail_links() throws Exception { + OffsetDateTime effectiveFrom = OffsetDateTime.parse("2026-08-26T12:00:00+09:00"); + DailyQuestionOverviewQuery query = DailyQuestionOverviewQuery.defaults(); + DailyQuestionOverviewRowViewModel question = new DailyQuestionOverviewRowViewModel( + 42L, + InterestCode.PREFERENCE, + "현재 질문 문구", + 2, + 2, + null, + effectiveFrom, + 10L, + 4L, + 3L, + 3L, + 15L, + 6L, + 4L, + 5L + ); + when(questionStatsService.getQuestionOverview(query)).thenReturn(new DailyQuestionOverviewViewModel( + List.of(question), + 875, + query, + ANALYTICS_BASELINE, + "2026-08-26 12:30:00" + )); + + mockMvc.perform(get("/stats/question/overview")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("전체 질문 현황"))) + .andExpect(content().string(containsString("name=\"keyword\""))) + .andExpect(content().string(containsString("name=\"interestCode\""))) + .andExpect(content().string(containsString("name=\"questionLevel\""))) + .andExpect(content().string(containsString("name=\"active\""))) + .andExpect(content().string(containsString("name=\"minimumCurrentExposureCount\""))) + .andExpect(content().string(containsString("name=\"sort\""))) + .andExpect(content().string(containsString("name=\"direction\""))) + .andExpect(content().string(containsString("875"))) + .andExpect(content().string(containsString("CURRENT REVISION"))) + .andExpect(content().string(containsString("현재 Revision 반응"))) + .andExpect(content().string(containsString("전체 Revision 누적"))) + .andExpect(content().string(containsString("현재 질문 문구"))) + .andExpect(content().string(containsString("#42"))) + .andExpect(content().string(containsString("40.0%"))) + .andExpect(content().string(containsString("30.0%"))) + .andExpect(content().string(containsString("data-sort=\"CURRENT_REROLL_RATE\""))) + .andExpect(content().string(containsString("CSV 다운로드"))) + .andExpect(content().string(containsString("href=\"/stats/question/overview.csv"))) + .andExpect(content().string(containsString("href=\"/stats/question?questionId=42\""))) + .andExpect(content().string(containsString("집계 시작 기준"))) + .andExpect(content().string(containsString("질문 반응 추적 기능이 배포된 시점 이후"))) + .andExpect(content().string(containsString("(2026년 8월 25일 12:00 KST)"))) + .andExpect(content().string(containsString("tab-link active\" href=\"/stats/question\""))); + } + + @Test + void questionOverview_preserves_filters_and_sort_order() throws Exception { + DailyQuestionOverviewQuery query = new DailyQuestionOverviewQuery( + "노래", + InterestCode.EMOTION, + 2, + false, + 10L, + DailyQuestionOverviewSort.CURRENT_REROLL_RATE, + DailyQuestionOverviewSortDirection.ASC + ); + when(questionStatsService.getQuestionOverview(query)).thenReturn(new DailyQuestionOverviewViewModel( + List.of(), + 875, + query, + ANALYTICS_BASELINE, + "2026-08-26 12:30:00" + )); + + String html = mockMvc.perform(get("/stats/question/overview") + .param("keyword", "노래") + .param("interestCode", "EMOTION") + .param("questionLevel", "2") + .param("active", "false") + .param("minimumCurrentExposureCount", "10") + .param("sort", "CURRENT_REROLL_RATE") + .param("direction", "ASC")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("value=\"노래\""))) + .andExpect(content().string(containsString("value=\"10\""))) + .andExpect(content().string(containsString("조건에 맞는 질문이 없습니다."))) + .andReturn().getResponse().getContentAsString(); + + assertThat(html) + .containsPattern("") + .containsPattern("") + .containsPattern("") + .containsPattern("") + .containsPattern("") + .contains( + "--sort-ascending: #ff5c68", + ".question-table.sort-ascending .sort-indicator", + "question-table sort-ascending", + "sort-indicator\">↑", + "interestCode=EMOTION", + "questionLevel=2", + "active=false", + "minimumCurrentExposureCount=10", + "sort=CURRENT_REROLL_RATE", + "direction=ASC" + ); + + verify(questionStatsService).getQuestionOverview(query); + } + + @Test + void questionOverviewCsv_downloads_filtered_and_sorted_rows() throws Exception { + DailyQuestionOverviewQuery query = new DailyQuestionOverviewQuery( + "노래", + InterestCode.EMOTION, + 2, + false, + 10L, + DailyQuestionOverviewSort.CURRENT_REROLL_RATE, + DailyQuestionOverviewSortDirection.ASC + ); + DailyQuestionOverviewRowViewModel question = new DailyQuestionOverviewRowViewModel( + 42L, + InterestCode.EMOTION, + "노래 질문", + 2, + 3, + OffsetDateTime.parse("2026-08-27T09:00:00+09:00"), + OffsetDateTime.parse("2026-08-26T12:00:00+09:00"), + 10L, + 4L, + 3L, + 3L, + 15L, + 6L, + 4L, + 5L + ); + when(questionStatsService.getQuestionOverview(query)).thenReturn(new DailyQuestionOverviewViewModel( + List.of(question), + 875, + query, + ANALYTICS_BASELINE, + "2026-08-27 10:00:00" + )); + + byte[] csvBytes = mockMvc.perform(get("/stats/question/overview.csv") + .param("keyword", "노래") + .param("interestCode", "EMOTION") + .param("questionLevel", "2") + .param("active", "false") + .param("minimumCurrentExposureCount", "10") + .param("sort", "CURRENT_REROLL_RATE") + .param("direction", "ASC")) + .andExpect(status().isOk()) + .andExpect(content().contentType("text/csv;charset=UTF-8")) + .andExpect(header().string( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"nadab_daily_question_stats.csv\"" + )) + .andReturn().getResponse().getContentAsByteArray(); + + assertThat(csvBytes).startsWith((byte) 0xEF, (byte) 0xBB, (byte) 0xBF); + assertThat(new String(csvBytes, StandardCharsets.UTF_8)) + .contains("질문 ID,질문 문구") + .contains("42,\"노래 질문\",\"EMOTION\",\"감정\"") + .contains(",10,4,40.0,3,30.0,3,15,6,40.0,4,5\r\n"); + verify(questionStatsService).getQuestionOverview(query); + } + + @Test + void questionStats_renders_selector_totals_and_revision_history() throws Exception { + OffsetDateTime effectiveFrom = OffsetDateTime.parse("2026-08-26T12:00:00+09:00"); + DailyQuestionListItemViewModel selectedQuestion = new DailyQuestionListItemViewModel( + 42L, + InterestCode.PREFERENCE, + "현재 질문 문구", + 2, + 2, + null + ); + DailyQuestionRevisionStatsViewModel revision = new DailyQuestionRevisionStatsViewModel( + 102L, + 2, + InterestCode.PREFERENCE, + "수정된 질문 문구", + 2, + "공감 가이드", + "힌트 가이드", + "리딩 질문 가이드", + null, + effectiveFrom, + "V20260826_1200", + 10L, + 4L, + 3L, + 3L + ); + when(questionStatsService.getQuestionStats(42L)).thenReturn(new DailyQuestionStatsViewModel( + List.of( + selectedQuestion, + new DailyQuestionListItemViewModel( + 43L, + InterestCode.EMOTION, + "다른 질문 문구", + 1, + 1, + null + ) + ), + selectedQuestion, + new DailyQuestionReactionStatsViewModel(15L, 6L, 4L, 5L), + List.of(revision), + ANALYTICS_BASELINE, + "2026-08-26 12:30:00" + )); + + String html = mockMvc.perform(get("/stats/question").param("questionId", "42")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("name=\"questionId\""))) + .andExpect(content().string(containsString("id=\"questionSearch\""))) + .andExpect(content().string(containsString("id=\"questionSelect\""))) + .andExpect(content().string(containsString("id=\"questionSearchStatus\""))) + .andExpect(content().string(containsString("id=\"questionSearchResults\""))) + .andExpect(content().string(containsString("검색 결과가 없습니다."))) + .andExpect(content().string(containsString("QUESTION #42"))) + .andExpect(content().string(containsString("누적 노출"))) + .andExpect(content().string(containsString("누적 답변"))) + .andExpect(content().string(containsString("40.0%"))) + .andExpect(content().string(containsString("Revision 2"))) + .andExpect(content().string(containsString("V20260826_1200"))) + .andExpect(content().string(containsString("공감 가이드"))) + .andExpect(content().string(containsString("집계 시작 기준"))) + .andExpect(content().string(containsString("질문 반응 추적 기능이 배포된 시점 이후"))) + .andExpect(content().string(containsString("(2026년 8월 25일 12:00 KST)"))) + .andExpect(content().string(containsString("배포 전 할당·답변은 추정하거나 현재 수정 버전에 소급하지 않습니다."))) + .andExpect(content().string(containsString("href=\"/stats/question\""))) + .andExpect(content().string(containsString("active\" href=\"/stats/question\""))) + .andReturn().getResponse().getContentAsString(); + + assertThat(html) + .contains( + "이 페이지를 읽는 방법", + "Question ID (질문 ID)", + "Revision (수정 버전)", + "교체가 곧 불호를 뜻하지는 않습니다.", + "예시로 이해하기", + "해석할 때 주의할 점", + "질문 반응 추적 기능 배포 전 데이터는 소급하지 않았습니다." + ); + assertThat(html.indexOf("Revision별 반응 및 수정 이력")) + .isLessThan(html.indexOf("이 페이지를 읽는 방법")); + } + + @Test + void questionStats_renders_empty_state_for_unknown_question() throws Exception { + when(questionStatsService.getQuestionStats(999L)).thenReturn(new DailyQuestionStatsViewModel( + List.of(), + null, + new DailyQuestionReactionStatsViewModel(0L, 0L, 0L, 0L), + List.of(), + ANALYTICS_BASELINE, + "2026-08-26 12:30:00" + )); + + mockMvc.perform(get("/stats/question").param("questionId", "999")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("해당 ID의 질문을 찾을 수 없습니다."))); + } + + @Test + void all_stats_templates_include_question_tab() throws Exception { + for (String template : List.of("daily", "weekly", "monthly", "type", "question", "question-overview", "withdrawal", "total")) { + String source = new ClassPathResource("templates/stats/" + template + ".html") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(source) + .as(template) + .contains("th:href=\"@{/stats/question}\"", ">질문"); + } } @Test diff --git a/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepositoryTest.java new file mode 100644 index 00000000..a1dd879b --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/stats/core/repository/QuestionStatsRepositoryTest.java @@ -0,0 +1,249 @@ +package com.devkor.ifive.nadab.domain.stats.core.repository; + +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestion; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposure; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionExposureSource; +import com.devkor.ifive.nadab.domain.question.core.entity.DailyQuestionRevision; +import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionListItemViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionOverviewRowViewModel; +import com.devkor.ifive.nadab.domain.stats.core.dto.question.DailyQuestionRevisionStatsViewModel; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuestionStatsRepository.class) +class QuestionStatsRepositoryTest extends PostgresIntegrationTestSupport { + + private static final LocalDate ASSIGNMENT_DATE = LocalDate.of(2026, 8, 26); + + @Autowired + QuestionStatsRepository repository; + + @Autowired + TestEntityManager em; + + @Test + void lists_questions_with_their_current_revision_state() { + insertSecondRevision(); + em.flush(); + em.clear(); + + DailyQuestionListItemViewModel question = repository.findQuestions().stream() + .filter(item -> item.questionId() == 1L) + .findFirst() + .orElseThrow(); + + assertThat(question.currentRevisionNo()).isEqualTo(2); + assertThat(question.questionText()).isEqualTo("수정된 질문"); + assertThat(question.active()).isTrue(); + } + + @Test + void finds_earliest_revision_one_effective_time_as_analytics_baseline() { + OffsetDateTime revisionOneEffectiveFrom = repository.findRevisionStats(1L).stream() + .filter(revision -> revision.revisionNo() == 1) + .findFirst() + .orElseThrow() + .effectiveFrom(); + + OffsetDateTime baselineEffectiveFrom = repository.findAnalyticsBaselineEffectiveFrom(); + + assertThat(baselineEffectiveFrom).isEqualTo(revisionOneEffectiveFrom); + } + + @Test + void aggregates_terminal_and_open_exposures_by_revision() { + insertSecondRevision(); + em.flush(); + em.clear(); + + DailyQuestion question = em.find(DailyQuestion.class, 1L); + DailyQuestionRevision revision1 = findRevision(1); + DailyQuestionRevision revision2 = findRevision(2); + + persistExposure(question, revision1, ExposureState.ANSWERED); + persistExposure(question, revision1, ExposureState.REROLLED); + persistExposure(question, revision1, ExposureState.OPEN); + persistExposure(question, revision2, ExposureState.ANSWERED); + persistExposure(question, revision2, ExposureState.OPEN); + em.flush(); + em.clear(); + + List stats = repository.findRevisionStats(1L); + + assertThat(stats).extracting(DailyQuestionRevisionStatsViewModel::revisionNo) + .containsExactly(2, 1); + + DailyQuestionRevisionStatsViewModel revision2Stats = stats.get(0); + assertThat(revision2Stats.questionText()).isEqualTo("수정된 질문"); + assertThat(revision2Stats.sourceMigration()).isEqualTo("V_TEST_2"); + assertThat(revision2Stats.exposureCount()).isEqualTo(2L); + assertThat(revision2Stats.answeredCount()).isEqualTo(1L); + assertThat(revision2Stats.rerolledCount()).isZero(); + assertThat(revision2Stats.unansweredCount()).isEqualTo(1L); + assertThat(revision2Stats.answerRate()).isEqualTo(0.5); + + DailyQuestionRevisionStatsViewModel revision1Stats = stats.get(1); + assertThat(revision1Stats.exposureCount()).isEqualTo(3L); + assertThat(revision1Stats.answeredCount()).isEqualTo(1L); + assertThat(revision1Stats.rerolledCount()).isEqualTo(1L); + assertThat(revision1Stats.unansweredCount()).isEqualTo(1L); + assertThat(revision1Stats.answerRate()).isCloseTo(1.0 / 3.0, within(0.0001)); + } + + @Test + void includes_revision_without_exposure_as_zero_counts() { + List stats = repository.findRevisionStats(2L); + + assertThat(stats).hasSize(1); + assertThat(stats.getFirst().exposureCount()).isZero(); + assertThat(stats.getFirst().answeredCount()).isZero(); + assertThat(stats.getFirst().rerolledCount()).isZero(); + assertThat(stats.getFirst().unansweredCount()).isZero(); + assertThat(stats.getFirst().answerRate()).isZero(); + } + + @Test + void aggregates_current_revision_and_all_revision_stats_per_question() { + insertSecondRevision(); + em.flush(); + em.clear(); + + DailyQuestion question = em.find(DailyQuestion.class, 1L); + DailyQuestionRevision revision1 = findRevision(1); + DailyQuestionRevision revision2 = findRevision(2); + + persistExposure(question, revision1, ExposureState.ANSWERED); + persistExposure(question, revision1, ExposureState.REROLLED); + persistExposure(question, revision1, ExposureState.OPEN); + persistExposure(question, revision2, ExposureState.ANSWERED); + persistExposure(question, revision2, ExposureState.OPEN); + em.flush(); + em.clear(); + + List overview = repository.findQuestionOverview(); + + DailyQuestionOverviewRowViewModel question1 = overview.stream() + .filter(row -> row.questionId() == 1L) + .findFirst() + .orElseThrow(); + assertThat(question1.questionText()).isEqualTo("수정된 질문"); + assertThat(question1.currentRevisionNo()).isEqualTo(2); + assertThat(question1.currentRevisionEffectiveFrom()) + .isEqualTo(OffsetDateTime.parse("2026-08-26T00:00:00+09:00")); + assertThat(question1.currentExposureCount()).isEqualTo(2L); + assertThat(question1.currentAnsweredCount()).isEqualTo(1L); + assertThat(question1.currentRerolledCount()).isZero(); + assertThat(question1.currentUnansweredCount()).isEqualTo(1L); + assertThat(question1.currentAnswerRate()).isEqualTo(0.5); + assertThat(question1.currentRerollRate()).isZero(); + assertThat(question1.totalExposureCount()).isEqualTo(5L); + assertThat(question1.totalAnsweredCount()).isEqualTo(2L); + assertThat(question1.totalRerolledCount()).isEqualTo(1L); + assertThat(question1.totalUnansweredCount()).isEqualTo(2L); + assertThat(question1.totalAnswerRate()).isEqualTo(0.4); + + DailyQuestionOverviewRowViewModel question2 = overview.stream() + .filter(row -> row.questionId() == 2L) + .findFirst() + .orElseThrow(); + assertThat(question2.currentExposureCount()).isZero(); + assertThat(question2.totalExposureCount()).isZero(); + assertThat(question2.currentAnswerRate()).isZero(); + } + + private void insertSecondRevision() { + em.getEntityManager().createNativeQuery(""" + UPDATE daily_questions + SET current_revision_no = 2, + question_text = '수정된 질문' + WHERE id = 1 + """).executeUpdate(); + em.getEntityManager().createNativeQuery(""" + INSERT INTO daily_question_revisions ( + daily_question_id, + revision_no, + interest_id, + question_text, + question_level, + empathy_guide, + hint_guide, + leading_question_guide, + deleted_at, + effective_from, + source_migration + ) + SELECT + id, + 2, + interest_id, + question_text, + question_level, + empathy_guide, + hint_guide, + leading_question_guide, + deleted_at, + TIMESTAMPTZ '2026-08-26 00:00:00+09', + 'V_TEST_2' + FROM daily_questions + WHERE id = 1 + """).executeUpdate(); + } + + private DailyQuestionRevision findRevision(int revisionNo) { + return em.getEntityManager().createQuery(""" + select revision + from DailyQuestionRevision revision + where revision.dailyQuestion.id = 1 + and revision.revisionNo = :revisionNo + """, DailyQuestionRevision.class) + .setParameter("revisionNo", revisionNo) + .getSingleResult(); + } + + private void persistExposure( + DailyQuestion question, + DailyQuestionRevision revision, + ExposureState state + ) { + User user = new UserBuilder(em).build(); + UserDailyQuestion assignment = UserDailyQuestion.create(user, ASSIGNMENT_DATE, question); + em.persist(assignment); + + DailyQuestionExposure exposure = DailyQuestionExposure.create( + assignment, + revision, + ASSIGNMENT_DATE, + 0, + DailyQuestionExposureSource.INITIAL + ); + if (state == ExposureState.ANSWERED) { + exposure.markAnswered(); + } else if (state == ExposureState.REROLLED) { + exposure.markRerolled(); + } + em.persist(exposure); + } + + private enum ExposureState { + OPEN, + ANSWERED, + REROLLED + } +}