Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public class AskChatSessionController {
홈 진입만으로 새 채팅 세션을 생성하지 않습니다. </br>
사용자의 누적 답변 개수가 20개 이상인 경우에만 물어보기 홈을 조회할 수 있습니다. </br>
응답에는 남은 메시지 횟수, 사용자 닉네임, 보유 크리스탈 수, 예시 질문 목록을 포함합니다. </br>
예시 질문은 사용자별로 10분 단위로 갱신되며 같은 시간 구간에는 동일하게 유지됩니다. </br>
히스토리 목록은 이 API에서 반환하지 않으며, 별도 히스토리 API를 사용해야 합니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public record AskChatHomeResponse(
@Schema(description = "사용자가 보유한 크리스탈 개수", example = "100")
long crystalBalance,

@Schema(description = "홈 화면에 표시할 예시 질문 목록. 여러 주제 중 일부를 랜덤으로 제공합니다.")
@Schema(description = "홈 화면에 표시할 예시 질문 목록. 사용자별로 10분 단위로 갱신됩니다.")
List<AskChatSampleQuestionResponse> sampleQuestions
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -23,25 +23,20 @@
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
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;
Expand All @@ -61,7 +56,7 @@ public AskChatHomeResponse getHome(Long userId) {
askChatWallet.getTotalTurnBalance(),
user.getNickname(),
userWallet.getCrystalBalance(),
pickSampleQuestions()
selectSampleQuestions(userId)
);
}

Expand Down Expand Up @@ -92,27 +87,14 @@ private AskChatSession createSession(Long userId) {
return askChatSessionRepository.save(AskChatSession.start(user));
}

private List<AskChatSampleQuestionResponse> pickSampleQuestions() {
Map<InterestCode, List<AskChatSampleQuestion>> questionsByCategory = askChatSampleQuestionRepository
.findByActiveTrueOrderByDisplayOrderAsc()
.stream()
.collect(Collectors.groupingBy(
AskChatSampleQuestion::getInterestCode,
LinkedHashMap::new,
Collectors.toList()
));

List<InterestCode> categories = new ArrayList<>(questionsByCategory.keySet());
Collections.shuffle(categories);
private List<AskChatSampleQuestionResponse> selectSampleQuestions(Long userId) {
List<AskChatSampleQuestion> 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<AskChatSampleQuestion> sampleQuestions) {
return sampleQuestions.get(ThreadLocalRandom.current().nextInt(sampleQuestions.size()));
}
}
Original file line number Diff line number Diff line change
@@ -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<AskChatSampleQuestion> select(
Long userId,
Instant now,
List<AskChatSampleQuestion> sampleQuestions
) {
Map<InterestCode, List<AskChatSampleQuestion>> questionsByCategory = groupByCategory(sampleQuestions);
if (questionsByCategory.isEmpty()) {
return List.of();
}

long rotationSlot = Math.floorDiv(now.getEpochSecond(), ROTATION_INTERVAL_SECONDS);
List<InterestCode> categories = orderedCategories(userId, questionsByCategory);
int categoryStartIndex = rotatedIndex(mix(userId), rotationSlot, categories.size());
int resultSize = Math.min(SAMPLE_QUESTION_SIZE, categories.size());

List<AskChatSampleQuestion> selectedQuestions = new ArrayList<>(resultSize);
for (int index = 0; index < resultSize; index++) {
InterestCode category = categories.get((categoryStartIndex + index) % categories.size());
List<AskChatSampleQuestion> 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<InterestCode, List<AskChatSampleQuestion>> groupByCategory(
List<AskChatSampleQuestion> sampleQuestions
) {
Map<InterestCode, List<AskChatSampleQuestion>> questionsByCategory =
new EnumMap<>(InterestCode.class);

for (AskChatSampleQuestion sampleQuestion : sampleQuestions) {
questionsByCategory
.computeIfAbsent(sampleQuestion.getInterestCode(), ignored -> new ArrayList<>())
.add(sampleQuestion);
}

Comparator<AskChatSampleQuestion> questionOrder = Comparator
.comparingInt(AskChatSampleQuestion::getDisplayOrder)
.thenComparing(AskChatSampleQuestion::getQuestion);
questionsByCategory.values().forEach(questions -> questions.sort(questionOrder));
return questionsByCategory;
}

private List<InterestCode> orderedCategories(
Long userId,
Map<InterestCode, List<AskChatSampleQuestion>> questionsByCategory
) {
List<InterestCode> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -51,6 +55,9 @@ class AskChatSessionServiceTest {
@Mock
private AskChatSampleQuestionRepository askChatSampleQuestionRepository;

@Mock
private AskChatSampleQuestionSelector askChatSampleQuestionSelector;

@Mock
private UserWalletRepository userWalletRepository;

Expand All @@ -71,6 +78,7 @@ void setUp() {
askChatSessionRepository,
askChatWalletRepository,
askChatSampleQuestionRepository,
askChatSampleQuestionSelector,
userWalletRepository,
userRepository,
answerEntryRepository,
Expand All @@ -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<AskChatSampleQuestion> sampleQuestions = List.of(
AskChatSampleQuestion.create(InterestCode.VALUES, "나는 어떤 사람이야?", 1),
AskChatSampleQuestion.create(InterestCode.PREFERENCE, "내가 좋아하는 것들의 공통점은 뭐야?", 2),
AskChatSampleQuestion.create(InterestCode.RELATIONSHIP, "어떤 사람과 잘 맞을까?", 3)
));
);
List<AskChatSampleQuestion> 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);

Expand All @@ -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());
}

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AskChatSampleQuestion> 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<AskChatSampleQuestion> 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<AskChatSampleQuestion> 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<AskChatSampleQuestion> questions = questionsForAllCategories();
List<AskChatSampleQuestion> 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<AskChatSampleQuestion> 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<AskChatSampleQuestion> questionsForAllCategories() {
List<AskChatSampleQuestion> 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);
}
}
Loading