diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java index c9ae2f0a..08cf29c5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/AskChatSessionController.java @@ -47,6 +47,7 @@ public class AskChatSessionController { 홈 진입만으로 새 채팅 세션을 생성하지 않습니다.
사용자의 누적 답변 개수가 20개 이상인 경우에만 물어보기 홈을 조회할 수 있습니다.
응답에는 남은 메시지 횟수, 사용자 닉네임, 보유 크리스탈 수, 예시 질문 목록을 포함합니다.
+ 예시 질문은 사용자별로 10분 단위로 갱신되며 같은 시간 구간에는 동일하게 유지됩니다.
히스토리 목록은 이 API에서 반환하지 않으며, 별도 히스토리 API를 사용해야 합니다. """, security = @SecurityRequirement(name = "bearerAuth"), diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java index ec516a0a..5d3a3542 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/api/dto/response/AskChatHomeResponse.java @@ -15,7 +15,7 @@ public record AskChatHomeResponse( @Schema(description = "사용자가 보유한 크리스탈 개수", example = "100") long crystalBalance, - @Schema(description = "홈 화면에 표시할 예시 질문 목록. 여러 주제 중 일부를 랜덤으로 제공합니다.") + @Schema(description = "홈 화면에 표시할 예시 질문 목록. 사용자별로 10분 단위로 갱신됩니다.") List sampleQuestions ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java index a55f3bd7..09b618e3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionService.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatRemainingMessageCountResponse; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatSampleQuestionResponse; +import com.devkor.ifive.nadab.domain.askchat.application.helper.AskChatSampleQuestionSelector; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSession; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; @@ -12,7 +13,6 @@ import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; 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.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; @@ -23,13 +23,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -37,11 +32,11 @@ public class AskChatSessionService { public static final int MAX_TURN_COUNT = 15; public static final int MIN_ANSWER_COUNT_TO_USE_ASK_CHAT = 20; - private static final int HOME_SAMPLE_QUESTION_SIZE = 3; private final AskChatSessionRepository askChatSessionRepository; private final AskChatWalletRepository askChatWalletRepository; private final AskChatSampleQuestionRepository askChatSampleQuestionRepository; + private final AskChatSampleQuestionSelector askChatSampleQuestionSelector; private final UserWalletRepository userWalletRepository; private final UserRepository userRepository; private final AnswerEntryRepository answerEntryRepository; @@ -61,7 +56,7 @@ public AskChatHomeResponse getHome(Long userId) { askChatWallet.getTotalTurnBalance(), user.getNickname(), userWallet.getCrystalBalance(), - pickSampleQuestions() + selectSampleQuestions(userId) ); } @@ -92,27 +87,14 @@ private AskChatSession createSession(Long userId) { return askChatSessionRepository.save(AskChatSession.start(user)); } - private List pickSampleQuestions() { - Map> questionsByCategory = askChatSampleQuestionRepository - .findByActiveTrueOrderByDisplayOrderAsc() - .stream() - .collect(Collectors.groupingBy( - AskChatSampleQuestion::getInterestCode, - LinkedHashMap::new, - Collectors.toList() - )); - - List categories = new ArrayList<>(questionsByCategory.keySet()); - Collections.shuffle(categories); + private List selectSampleQuestions(Long userId) { + List sampleQuestions = askChatSampleQuestionRepository + .findByActiveTrueOrderByDisplayOrderAsc(); - return categories.stream() - .limit(HOME_SAMPLE_QUESTION_SIZE) - .map(category -> pickOne(questionsByCategory.get(category))) + return askChatSampleQuestionSelector + .select(userId, Instant.now(), sampleQuestions) + .stream() .map(AskChatSampleQuestionResponse::from) .toList(); } - - private AskChatSampleQuestion pickOne(List sampleQuestions) { - return sampleQuestions.get(ThreadLocalRandom.current().nextInt(sampleQuestions.size())); - } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java new file mode 100644 index 00000000..07246af4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelector.java @@ -0,0 +1,94 @@ +package com.devkor.ifive.nadab.domain.askchat.application.helper; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Component +public class AskChatSampleQuestionSelector { + + private static final int SAMPLE_QUESTION_SIZE = 3; + private static final long ROTATION_INTERVAL_SECONDS = Duration.ofMinutes(10).toSeconds(); + private static final long CATEGORY_SALT = 0x9E3779B97F4A7C15L; + + public List select( + Long userId, + Instant now, + List sampleQuestions + ) { + Map> questionsByCategory = groupByCategory(sampleQuestions); + if (questionsByCategory.isEmpty()) { + return List.of(); + } + + long rotationSlot = Math.floorDiv(now.getEpochSecond(), ROTATION_INTERVAL_SECONDS); + List categories = orderedCategories(userId, questionsByCategory); + int categoryStartIndex = rotatedIndex(mix(userId), rotationSlot, categories.size()); + int resultSize = Math.min(SAMPLE_QUESTION_SIZE, categories.size()); + + List selectedQuestions = new ArrayList<>(resultSize); + for (int index = 0; index < resultSize; index++) { + InterestCode category = categories.get((categoryStartIndex + index) % categories.size()); + List categoryQuestions = questionsByCategory.get(category); + int questionIndex = rotatedIndex( + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L))), + rotationSlot, + categoryQuestions.size() + ); + selectedQuestions.add(categoryQuestions.get(questionIndex)); + } + + return List.copyOf(selectedQuestions); + } + + private Map> groupByCategory( + List sampleQuestions + ) { + Map> questionsByCategory = + new EnumMap<>(InterestCode.class); + + for (AskChatSampleQuestion sampleQuestion : sampleQuestions) { + questionsByCategory + .computeIfAbsent(sampleQuestion.getInterestCode(), ignored -> new ArrayList<>()) + .add(sampleQuestion); + } + + Comparator questionOrder = Comparator + .comparingInt(AskChatSampleQuestion::getDisplayOrder) + .thenComparing(AskChatSampleQuestion::getQuestion); + questionsByCategory.values().forEach(questions -> questions.sort(questionOrder)); + return questionsByCategory; + } + + private List orderedCategories( + Long userId, + Map> questionsByCategory + ) { + List categories = new ArrayList<>(questionsByCategory.keySet()); + categories.sort(Comparator + .comparingLong((InterestCode category) -> + mix(userId ^ (CATEGORY_SALT * (category.ordinal() + 1L)))) + .thenComparing(InterestCode::name)); + return categories; + } + + private int rotatedIndex(long base, long rotationSlot, int size) { + int baseIndex = Math.floorMod(base, size); + int slotOffset = Math.floorMod(rotationSlot, size); + return (baseIndex + slotOffset) % size; + } + + private long mix(long value) { + value = (value ^ (value >>> 33)) * 0xff51afd7ed558ccdl; + value = (value ^ (value >>> 33)) * 0xc4ceb9fe1a85ec53l; + return value ^ (value >>> 33); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java index 0bad4036..fd5b5408 100644 --- a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/AskChatSessionServiceTest.java @@ -5,6 +5,7 @@ import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSessionStatus; import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatWallet; import com.devkor.ifive.nadab.domain.askchat.api.dto.response.AskChatQuestionSendResponse; +import com.devkor.ifive.nadab.domain.askchat.application.helper.AskChatSampleQuestionSelector; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSampleQuestionRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatSessionRepository; import com.devkor.ifive.nadab.domain.askchat.core.repository.AskChatWalletRepository; @@ -24,6 +25,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; @@ -32,6 +34,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -51,6 +55,9 @@ class AskChatSessionServiceTest { @Mock private AskChatSampleQuestionRepository askChatSampleQuestionRepository; + @Mock + private AskChatSampleQuestionSelector askChatSampleQuestionSelector; + @Mock private UserWalletRepository userWalletRepository; @@ -71,6 +78,7 @@ void setUp() { askChatSessionRepository, askChatWalletRepository, askChatSampleQuestionRepository, + askChatSampleQuestionSelector, userWalletRepository, userRepository, answerEntryRepository, @@ -86,11 +94,23 @@ void getHome_returns_home_display_data_without_creating_session() { when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(askChatWalletRepository.findByUserId(1L)).thenReturn(Optional.of(AskChatWallet.create(user, 2, 7))); when(userWalletRepository.findByUserId(1L)).thenReturn(Optional.of(UserWallet.create(user, 100L))); - when(askChatSampleQuestionRepository.findByActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of( + List sampleQuestions = List.of( AskChatSampleQuestion.create(InterestCode.VALUES, "나는 어떤 사람이야?", 1), AskChatSampleQuestion.create(InterestCode.PREFERENCE, "내가 좋아하는 것들의 공통점은 뭐야?", 2), AskChatSampleQuestion.create(InterestCode.RELATIONSHIP, "어떤 사람과 잘 맞을까?", 3) - )); + ); + List selectedQuestions = List.of( + sampleQuestions.get(2), + sampleQuestions.get(0), + sampleQuestions.get(1) + ); + when(askChatSampleQuestionRepository.findByActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(sampleQuestions); + when(askChatSampleQuestionSelector.select( + eq(1L), + any(Instant.class), + same(sampleQuestions) + )).thenReturn(selectedQuestions); var response = service.getHome(1L); @@ -100,7 +120,12 @@ void getHome_returns_home_display_data_without_creating_session() { assertThat(response.sampleQuestions()).hasSize(3); assertThat(response.sampleQuestions()) .extracting("category") - .containsExactlyInAnyOrder("VALUES", "PREFERENCE", "RELATIONSHIP"); + .containsExactly("RELATIONSHIP", "VALUES", "PREFERENCE"); + verify(askChatSampleQuestionSelector).select( + eq(1L), + any(Instant.class), + same(sampleQuestions) + ); verify(askChatSessionRepository, never()).save(any()); } @@ -115,6 +140,7 @@ void getHome_rejects_when_answer_count_is_less_than_minimum() { verifyNoInteractions(askChatWalletRepository); verifyNoInteractions(userWalletRepository); verifyNoInteractions(askChatSampleQuestionRepository); + verifyNoInteractions(askChatSampleQuestionSelector); verify(askChatSessionRepository, never()).save(any()); verifyNoInteractions(askChatMessageCommandService); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java new file mode 100644 index 00000000..cf60cf92 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/askchat/application/helper/AskChatSampleQuestionSelectorTest.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.askchat.application.helper; + +import com.devkor.ifive.nadab.domain.askchat.core.entity.AskChatSampleQuestion; +import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AskChatSampleQuestionSelectorTest { + + private final AskChatSampleQuestionSelector selector = new AskChatSampleQuestionSelector(); + + @Test + void select_returns_same_questions_within_same_ten_minute_slot() { + List questions = questionsForAllCategories(); + + var first = selector.select(1L, Instant.parse("2026-08-15T00:00:00Z"), questions); + var last = selector.select(1L, Instant.parse("2026-08-15T00:09:59Z"), questions); + + assertThat(last).containsExactlyElementsOf(first); + } + + @Test + void select_rotates_questions_when_ten_minute_slot_changes() { + List questions = questionsForAllCategories(); + + var before = selector.select(1L, Instant.parse("2026-08-15T00:09:59Z"), questions); + var after = selector.select(1L, Instant.parse("2026-08-15T00:10:00Z"), questions); + + assertThat(after).isNotEqualTo(before); + assertThat(after) + .extracting(AskChatSampleQuestion::getInterestCode) + .doesNotHaveDuplicates(); + } + + @Test + void select_distributes_questions_by_user() { + List questions = questionsForAllCategories(); + Instant now = Instant.parse("2026-08-15T00:05:00Z"); + + var firstUser = selector.select(1L, now, questions); + var secondUser = selector.select(2L, now, questions); + + assertThat(secondUser).isNotEqualTo(firstUser); + } + + @Test + void select_is_independent_of_input_order() { + List questions = questionsForAllCategories(); + List reversedQuestions = new ArrayList<>(questions); + Collections.reverse(reversedQuestions); + Instant now = Instant.parse("2026-08-15T00:05:00Z"); + + var originalOrder = selector.select(1L, now, questions); + var reversedOrder = selector.select(1L, now, reversedQuestions); + + assertThat(reversedOrder).containsExactlyElementsOf(originalOrder); + } + + @Test + void select_returns_available_categories_when_fewer_than_three_exist() { + List questions = List.of( + question(InterestCode.PREFERENCE, "취향 질문", 2), + question(InterestCode.VALUES, "가치관 질문", 1) + ); + + var selected = selector.select(1L, Instant.parse("2026-08-15T00:05:00Z"), questions); + + assertThat(selected).hasSize(2); + assertThat(selected) + .extracting(AskChatSampleQuestion::getInterestCode) + .containsExactlyInAnyOrder(InterestCode.PREFERENCE, InterestCode.VALUES); + } + + @Test + void select_returns_empty_list_when_no_questions_exist() { + var selected = selector.select(1L, Instant.parse("2026-08-15T00:05:00Z"), List.of()); + + assertThat(selected).isEmpty(); + } + + private List questionsForAllCategories() { + List questions = new ArrayList<>(); + int displayOrder = 1; + for (InterestCode category : InterestCode.values()) { + questions.add(question(category, category.name() + " 질문 A", displayOrder++)); + questions.add(question(category, category.name() + " 질문 B", displayOrder++)); + } + return questions; + } + + private AskChatSampleQuestion question(InterestCode category, String question, int displayOrder) { + return AskChatSampleQuestion.create(category, question, displayOrder); + } +}