diff --git a/build.gradle.kts b/build.gradle.kts index 0f7d13a4..0b8aa372 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,6 +87,9 @@ dependencies { // Google Gemini AI implementation(Dependencies.Ai.GEMINI) + // Cache (Caffeine) + implementation(Dependencies.Cache.CAFFEINE) + compileOnly(Dependencies.Lombok.LOMBOK) annotationProcessor(Dependencies.Lombok.LOMBOK) testImplementation(Dependencies.SpringBoot.TEST) diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 39de948a..62c47c3c 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -51,4 +51,8 @@ object Dependencies { object Ai { const val GEMINI = "com.google.cloud:google-cloud-vertexai:1.14.0" } + + object Cache { + const val CAFFEINE = "com.github.ben-manes.caffeine:caffeine:3.1.8" + } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6129e06c..1a0bd3de 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -151,6 +151,8 @@ if [ -n "${GEMINI_PROJECT_ID:-}" ]; then DOCKER_OPTS+=( -e GEMINI_PROJECT_ID -e GEMINI_ENABLED + -e GEMINI_MODEL + -e GEMINI_LOCATION ) log "Gemini 설정 추가됨" fi diff --git a/scripts/qa-seed.sql b/scripts/qa-seed.sql deleted file mode 100644 index afdcb74e..00000000 --- a/scripts/qa-seed.sql +++ /dev/null @@ -1,270 +0,0 @@ --- ============================================================================= --- QA 더미 데이터 시드 스크립트 (운영 DB / PostgreSQL · Neon) --- ============================================================================= --- 실행 전 필수: --- 1) 아래 __MY_USER_ID__ 를 본인 user id 숫자로 전부 치환 (찾는 법: 파일 하단 주석) --- 2) 가능하면 Neon에서 브랜치/스냅샷 한 번 떠두고 실행 --- 3) 한 트랜잭션으로 실행됨 → 중간 에러 시 전체 롤백 --- --- 정리(삭제)는 짝꿍 스크립트 scripts/qa-cleanup.sql 로 수행. --- 심는 데이터는 모두 식별자가 박혀 있어 안전하게 회수 가능: --- - 더미 유저 : users.sub LIKE 'qa-dummy-%' --- - 더미 투표 : qa_seed_vote 추적 테이블에 id 기록 --- - 더미 게스트: guest_free_vote.anonymous_id LIKE 'qa-guest-%' --- ============================================================================= - -BEGIN; - --- 진행/종료는 status 컬럼이 아니라 end_at vs now() 로만 판정됨(코드 확인). end_at만 잘 잡으면 됨. - --- 0) 더미 투표 id 추적 테이블 ------------------------------------------------- -CREATE TABLE IF NOT EXISTS qa_seed_vote ( - id BIGINT PRIMARY KEY, - seq INT, - kind TEXT -- 'ongoing' | 'ended' -); - --- 1) 더미 유저 60명 (성별/연령 다양) ----------------------------------------- -INSERT INTO users (sub, nickname, profile_icon_url, birth_year, gender, image_color, email, user_status) -SELECT - 'qa-dummy-' || g, - 'QA유저' || lpad(g::text, 2, '0'), - NULL, - 1975 + (g % 35), -- 1975 ~ 2009 분포 - CASE WHEN g % 2 = 0 THEN 'MALE' ELSE 'FEMALE' END, - (ARRAY['GREEN','RED','BLUE','YELLOW'])[1 + (g % 4)], - 'qa-dummy-' || g || '@example.com', - 'REGISTER' -FROM generate_series(1, 60) AS g; - --- 더미 유저 알림설정 row (push 기본 off) -INSERT INTO notification_setting (user_id) -SELECT id FROM users WHERE sub LIKE 'qa-dummy-%' -ON CONFLICT (user_id) DO NOTHING; - --- 2) 진행 중 투표 50개 -------------------------------------------------------- --- seq 1 : 종료 임박 1h --- seq 2 : 종료 임박 12h --- seq 10,11,12 : 참여자 최다(60명)+조회수 높음 → 핫토픽 TOP3 후보 --- seq 49 : 참여자 0명 --- seq 5,6,7 : 오늘의 추천 대상 -WITH new_votes AS ( - INSERT INTO vote (type, title, content, thumbnail_url, image_url, status, end_at, created_at, updated_at, ai_insight_headline, ai_insight_body) - SELECT - 'GENERAL', - 'QA 진행중 투표 #' || g, - '진행 중 투표 본문입니다. 번호 ' || g, - 'https://picsum.photos/seed/qa-on-' || g || '/400/300', - NULL, - 'ONGOING', - CASE - WHEN g = 1 THEN now() + interval '1 hour' -- 종료 임박 1h - WHEN g = 2 THEN now() + interval '12 hours' -- 종료 임박 12h - ELSE now() + (((g % 10) + 1) || ' days')::interval - END, - now() - ((g % 48 + 1) || ' hours')::interval, - now(), - NULL, NULL - FROM generate_series(1, 50) AS g - RETURNING id, title -) -INSERT INTO qa_seed_vote (id, seq, kind) -SELECT id, split_part(title, '#', 2)::int, 'ongoing' FROM new_votes; - --- 3) 종료된 투표 7개 ---------------------------------------------------------- --- seq 1 : 테스트계정 참여O + 채팅 5건 --- seq 2 : 테스트계정 참여O + 채팅 300건 초과 --- seq 3 : 테스트계정 참여X (다른 유저는 참여) --- seq 4 : 성별/연령 분포 다양 (도넛·막대 차트용, 50명 참여) --- seq 5 : AI 인사이트 생성 완료 --- seq 6 : 어제 종료 --- seq 7 : 일주일 전 종료 -WITH new_votes AS ( - INSERT INTO vote (type, title, content, thumbnail_url, image_url, status, end_at, created_at, updated_at, ai_insight_headline, ai_insight_body) - SELECT - 'GENERAL', - 'QA 종료 투표 #' || g, - '종료된 투표 본문입니다. 번호 ' || g, - 'https://picsum.photos/seed/qa-end-' || g || '/400/300', - NULL, - 'ENDED', - CASE - WHEN g = 6 THEN now() - interval '1 day' -- 어제 종료 - WHEN g = 7 THEN now() - interval '7 days' -- 일주일 전 종료 - ELSE now() - ((g + 1) || ' days')::interval -- 2~6일 전 - END, - now() - interval '14 days', - now(), - CASE WHEN g = 5 THEN '응답자의 68%가 찬성했어요' ELSE NULL END, - CASE WHEN g = 5 THEN '20대 여성층의 찬성 비율이 특히 높았고, 연령이 높아질수록 반대 의견이 늘어나는 경향을 보였습니다. 전체적으로 활발한 참여가 이루어진 투표였어요.' ELSE NULL END - FROM generate_series(1, 7) AS g - RETURNING id, title -) -INSERT INTO qa_seed_vote (id, seq, kind) -SELECT id, split_part(title, '#', 2)::int, 'ended' FROM new_votes; - --- 4) 모든 투표에 선택지 2개 --------------------------------------------------- -INSERT INTO vote_option (vote_id, label, position) -SELECT v.id, opt.label, opt.position -FROM qa_seed_vote v -CROSS JOIN (VALUES ('찬성', 0), ('반대', 1)) AS opt(label, position); - --- 5) 더미 유저 참여 (진행중) -------------------------------------------------- -INSERT INTO vote_participation (vote_id, user_id, anonymous_id, option_id, created_at, updated_at) -SELECT - v.id, - u.id, - NULL, - (SELECT o.id FROM vote_option o WHERE o.vote_id = v.id ORDER BY o.position LIMIT 1 OFFSET (u.rn % 2)), - now() - (u.rn || ' minutes')::interval, - now() -FROM qa_seed_vote v -JOIN ( - SELECT id, row_number() OVER (ORDER BY id) AS rn - FROM users WHERE sub LIKE 'qa-dummy-%' -) u - ON u.rn <= CASE - WHEN v.seq IN (10, 11, 12) THEN 60 -- 핫토픽 후보: 최다 - WHEN v.seq = 49 THEN 0 -- 참여자 0명 - ELSE (v.seq % 25) + 3 -- 3 ~ 27명 다양 - END -WHERE v.kind = 'ongoing'; - --- 6) 더미 유저 참여 (종료) ---------------------------------------------------- -INSERT INTO vote_participation (vote_id, user_id, anonymous_id, option_id, created_at, updated_at) -SELECT - v.id, - u.id, - NULL, - (SELECT o.id FROM vote_option o WHERE o.vote_id = v.id ORDER BY o.position LIMIT 1 OFFSET (u.rn % 2)), - now() - interval '8 days' - (u.rn || ' minutes')::interval, - now() -FROM qa_seed_vote v -JOIN ( - SELECT id, row_number() OVER (ORDER BY id) AS rn - FROM users WHERE sub LIKE 'qa-dummy-%' -) u - ON u.rn <= CASE - WHEN v.seq = 4 THEN 50 -- 성별/연령 차트용 50명 - ELSE 20 -- 결과 페이지용 기본 20명 - END -WHERE v.kind = 'ended'; - --- 7) 테스트(본인) 계정 참여 -------------------------------------------------- --- 진행중 일부 + 종료 seq 1,2,4,5,6,7 (seq 3 은 일부러 미참여) -INSERT INTO vote_participation (vote_id, user_id, anonymous_id, option_id, created_at, updated_at) -SELECT - v.id, - __MY_USER_ID__, - NULL, - (SELECT o.id FROM vote_option o WHERE o.vote_id = v.id ORDER BY o.position LIMIT 1), - v_created.ts, - now() -FROM qa_seed_vote v -CROSS JOIN LATERAL (SELECT now() - interval '6 days' AS ts) v_created -WHERE (v.kind = 'ended' AND v.seq IN (1, 2, 4, 5, 6, 7)) - OR (v.kind = 'ongoing' AND v.seq IN (3, 11, 20)); - --- 8) 채팅 메시지 ------------------------------------------------------------- --- 8-1) 진행중 seq 10 : 채팅 활발 (35건) -INSERT INTO chat_message (vote_id, sender_id, content, created_at, updated_at) -SELECT - v.id, - (SELECT id FROM users WHERE sub = 'qa-dummy-' || (1 + (n % 60))), - 'QA 실시간 채팅 메시지 ' || n, - now() - (n || ' minutes')::interval, - now() -FROM qa_seed_vote v -CROSS JOIN generate_series(1, 35) AS n -WHERE v.kind = 'ongoing' AND v.seq = 10; - --- 8-2) 종료 seq 1 : 채팅 5건 -INSERT INTO chat_message (vote_id, sender_id, content, created_at, updated_at) -SELECT - v.id, - (SELECT id FROM users WHERE sub = 'qa-dummy-' || (1 + (n % 60))), - 'QA 종료투표 채팅 ' || n, - now() - interval '8 days' + (n || ' minutes')::interval, - now() -FROM qa_seed_vote v -CROSS JOIN generate_series(1, 5) AS n -WHERE v.kind = 'ended' AND v.seq = 1; - --- 8-3) 종료 seq 2 : 채팅 320건 (300+ 뱃지) -INSERT INTO chat_message (vote_id, sender_id, content, created_at, updated_at) -SELECT - v.id, - (SELECT id FROM users WHERE sub = 'qa-dummy-' || (1 + (n % 60))), - 'QA 대량 채팅 메시지 ' || n, - now() - interval '9 days' + (n || ' seconds')::interval, - now() -FROM qa_seed_vote v -CROSS JOIN generate_series(1, 320) AS n -WHERE v.kind = 'ended' AND v.seq = 2; - --- 9) 조회수 통계 (핫토픽 점수 = 참여*0.7 + 조회*0.3) -------------------------- -INSERT INTO vote_statistics (vote_id, view_count) -SELECT - v.id, - CASE - WHEN v.kind = 'ongoing' AND v.seq = 10 THEN 5000 - WHEN v.kind = 'ongoing' AND v.seq = 11 THEN 3500 - WHEN v.kind = 'ongoing' AND v.seq = 12 THEN 2500 - ELSE (v.seq * 37) % 800 -- 그 외 잡다한 조회수 - END -FROM qa_seed_vote v; - --- 10) 오늘의 추천 3개 (진행중 투표만 / 운영진 선정 처리됨) --------------------- -INSERT INTO recommended_vote (vote_id, display_order, recommended_date, created_at, updated_at) -SELECT - v.id, - row_number() OVER (ORDER BY v.seq), - CURRENT_DATE, - now(), - now() -FROM qa_seed_vote v -WHERE v.kind = 'ongoing' AND v.seq IN (5, 6, 7); - --- 11) 알림 3건 (테스트 계정, 모두 미읽음) ------------------------------------ -INSERT INTO notification (user_id, type, vote_id, title, body, thumbnail_url, is_read, created_at, sent, sent_at) -VALUES - (__MY_USER_ID__, 'VOTE_ENDED', - (SELECT id FROM qa_seed_vote WHERE kind='ended' AND seq=6), - '참여하신 투표가 종료되었어요', '결과를 확인해보세요! (24시간 이내 알림)', - NULL, FALSE, now() - interval '3 hours', TRUE, now() - interval '3 hours'), - (__MY_USER_ID__, 'VOTE_ENDED', - (SELECT id FROM qa_seed_vote WHERE kind='ended' AND seq=7), - '참여하신 투표가 종료되었어요', '결과를 확인해보세요! (7일 이내 알림)', - NULL, FALSE, now() - interval '3 days', TRUE, now() - interval '3 days'), - (__MY_USER_ID__, 'VOTE_ENDED', - (SELECT id FROM qa_seed_vote WHERE kind='ended' AND seq=4), - '참여하신 투표가 종료되었어요', '결과를 확인해보세요! (7일 초과 알림)', - NULL, FALSE, now() - interval '10 days', TRUE, now() - interval '10 days'); - --- 12) 비회원 무료 투표 잔여 횟수 조정 (MAX=5) -------------------------------- --- remaining = 5 - consumed_count. 잔여 1회로 세팅하려면 consumed_count = 4. -INSERT INTO guest_free_vote (anonymous_id, consumed_count, last_consumed_at, created_at, updated_at) -VALUES - ('qa-guest-1', 4, now() - interval '1 hour', now(), now()), -- 잔여 1회 - ('qa-guest-2', 0, NULL, now(), now()); -- 잔여 5회(풀) --- 운영 중 임의 조정 예시: UPDATE guest_free_vote SET consumed_count = 4 WHERE anonymous_id = '<실제 anonymousId>'; - --- 결과 요약 ------------------------------------------------------------------ -SELECT 'dummy_users' AS item, count(*) FROM users WHERE sub LIKE 'qa-dummy-%' -UNION ALL SELECT 'dummy_votes', count(*) FROM qa_seed_vote -UNION ALL SELECT 'participations', count(*) FROM vote_participation WHERE vote_id IN (SELECT id FROM qa_seed_vote) -UNION ALL SELECT 'chat_messages', count(*) FROM chat_message WHERE vote_id IN (SELECT id FROM qa_seed_vote) -UNION ALL SELECT 'notifications', count(*) FROM notification WHERE vote_id IN (SELECT id FROM qa_seed_vote); - -COMMIT; - --- ============================================================================= --- 본인 user id 찾는 법 --- 방법1) SELECT id, email, nickname FROM users WHERE email = 'cjunk0304@gmail.com'; --- 방법2) 액세스 토큰을 jwt.io 에 붙여넣고 sub 클레임 숫자 확인 --- → 찾은 숫자로 이 파일의 __MY_USER_ID__ 를 전부 치환 후 실행. --- --- 주의: '오늘의 추천 어드민 API' 자체를 테스트하려면 application.yml 의 --- admin.user-ids 목록(현재 [7])에 본인 id가 있어야 함. 위 시드는 추천 --- 데이터를 DB에 직접 넣으므로 그 설정 없이도 추천 목록은 노출됨. --- ============================================================================= diff --git a/src/integrationTest/java/com/ject/vs/ai/port/PersonalizedAiInsightIntegrationTest.java b/src/integrationTest/java/com/ject/vs/ai/port/PersonalizedAiInsightIntegrationTest.java new file mode 100644 index 00000000..b7c02fff --- /dev/null +++ b/src/integrationTest/java/com/ject/vs/ai/port/PersonalizedAiInsightIntegrationTest.java @@ -0,0 +1,334 @@ +package com.ject.vs.ai.port; + +import com.ject.vs.ai.port.in.AiInsightUseCase; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; +import com.ject.vs.ai.port.in.AiInsightUseCase.PersonalizedVoteInsightRequest; +import com.ject.vs.support.BaseIntegrationTest; +import com.ject.vs.user.domain.Gender; +import com.ject.vs.user.domain.User; +import com.ject.vs.user.domain.UserRepository; +import com.ject.vs.vote.domain.*; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase; +import com.ject.vs.vote.port.in.VoteResultQueryUseCase.VoteResultDetail; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Duration; +import java.time.Year; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@DisplayName("개인화 AI 인사이트 통합 테스트") +class PersonalizedAiInsightIntegrationTest extends BaseIntegrationTest { + + @Autowired + VoteRepository voteRepository; + + @Autowired + VoteOptionRepository voteOptionRepository; + + @Autowired + VoteParticipationRepository voteParticipationRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + VoteResultQueryUseCase voteResultQueryUseCase; + + @Autowired + PersonalizedInsightDataCollector dataCollector; + + @MockitoBean + AiInsightUseCase aiInsightUseCase; + + Vote vote; + VoteOption optionA; + VoteOption optionB; + User maleUser20s; + User femaleUser30s; + User maleUser30s; + + @BeforeEach + void setUpVoteAndUsers() { + // 종료된 투표 생성 + vote = Vote.create( + "짜장면 vs 짬뽕", + "중식 선호도 조사", + "https://example.com/thumb.jpg", + null, + Duration.ofHours(24), + clock + ); + vote = voteRepository.save(vote); + + // 투표 종료 처리 (endAt을 과거로) + entityManager.createQuery("UPDATE Vote v SET v.endAt = :past WHERE v.id = :id") + .setParameter("past", FIXED_NOW.minus(Duration.ofDays(1))) + .setParameter("id", vote.getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + vote = voteRepository.findById(vote.getId()).orElseThrow(); + + // 옵션 생성 + optionA = VoteOption.of(vote, "짜장면", 0); + optionB = VoteOption.of(vote, "짬뽕", 1); + optionA = voteOptionRepository.save(optionA); + optionB = voteOptionRepository.save(optionB); + + // 사용자 생성 + maleUser20s = createUser("male20s@test.com", Gender.MALE, Year.of(2000)); // 25세 → 20대 + femaleUser30s = createUser("female30s@test.com", Gender.FEMALE, Year.of(1992)); // 33세 → 30대 + maleUser30s = createUser("male30s@test.com", Gender.MALE, Year.of(1990)); // 35세 → 30대 + } + + User createUser(String email, Gender gender, Year birthYear) { + User user = User.createWithEmail(email); + user = userRepository.save(user); + + entityManager.createQuery( + "UPDATE User u SET u.gender = :gender, u.birthYear = :birthYear WHERE u.id = :id") + .setParameter("gender", gender) + .setParameter("birthYear", birthYear) + .setParameter("id", user.getId()) + .executeUpdate(); + entityManager.flush(); + entityManager.clear(); + + return userRepository.findById(user.getId()).orElseThrow(); + } + + void participate(User user, VoteOption option) { + VoteParticipation participation = VoteParticipation.ofMember( + vote.getId(), user.getId(), option.getId()); + voteParticipationRepository.save(participation); + entityManager.flush(); + } + + @Nested + @DisplayName("PersonalizedInsightDataCollector") + class DataCollectorTest { + + @Test + @DisplayName("투표 기본 통계를 정확하게 수집한다") + void collectsBasicVoteStatistics() { + // given: 3명 참여 - 짜장면 2명, 짬뽕 1명 + participate(maleUser20s, optionA); + participate(femaleUser30s, optionA); + participate(maleUser30s, optionB); + + // when + PersonalizedVoteInsightRequest request = dataCollector.collect( + vote.getId(), maleUser20s.getId(), optionA.getId()); + + // then + assertThat(request.voteTitle()).isEqualTo("짜장면 vs 짬뽕"); + assertThat(request.optionALabel()).isEqualTo("짜장면"); + assertThat(request.optionACount()).isEqualTo(2); + assertThat(request.optionARatio()).isEqualTo(67); // 2/3 ≈ 67% + assertThat(request.optionBLabel()).isEqualTo("짬뽕"); + assertThat(request.optionBCount()).isEqualTo(1); + assertThat(request.totalParticipants()).isEqualTo(3); + } + + @Test + @DisplayName("사용자 프로필 정보를 수집한다") + void collectsUserProfile() { + // given + participate(maleUser20s, optionA); + + // when + PersonalizedVoteInsightRequest request = dataCollector.collect( + vote.getId(), maleUser20s.getId(), optionA.getId()); + + // then + assertThat(request.userGender()).isEqualTo("MALE"); + assertThat(request.userAgeGroup()).isEqualTo("20s"); + assertThat(request.userSelectedOption()).isEqualTo("짜장면"); + } + + @Test + @DisplayName("같은 성별 비율을 계산한다") + void calculatesSameGenderRatio() { + // given: 남성 2명 중 1명이 짜장면, 1명이 짬뽕 선택 + participate(maleUser20s, optionA); + participate(maleUser30s, optionB); + participate(femaleUser30s, optionA); + + // when - maleUser20s 관점에서 조회 + PersonalizedVoteInsightRequest request = dataCollector.collect( + vote.getId(), maleUser20s.getId(), optionA.getId()); + + // then: 남성 2명 중 1명이 짜장면 선택 → 50% + assertThat(request.sameGenderRatio()).isEqualTo(50); + } + + @Test + @DisplayName("같은 연령대 비율을 계산한다") + void calculatesSameAgeGroupRatio() { + // given: 30대 2명 중 1명이 짜장면, 1명이 짬뽕 + participate(maleUser20s, optionA); + participate(femaleUser30s, optionA); + participate(maleUser30s, optionB); + + // when - femaleUser30s 관점에서 조회 + PersonalizedVoteInsightRequest request = dataCollector.collect( + vote.getId(), femaleUser30s.getId(), optionA.getId()); + + // then: 30대 2명 중 1명이 짜장면 선택 → 50% + assertThat(request.sameAgeGroupRatio()).isEqualTo(50); + } + } + + @Nested + @DisplayName("VoteResultQueryService 통합") + class VoteResultQueryTest { + + @Test + @DisplayName("투표 참여자는 개인화된 AI 인사이트를 받는다") + void participantGetsPersonalizedAiInsight() { + // given + participate(maleUser20s, optionA); + participate(femaleUser30s, optionB); + + AiInsightResult mockResult = new AiInsightResult( + "당신은 다수파입니다!", + "남성 20대의 100%가 짜장면을 선택했어요." + ); + given(aiInsightUseCase.generatePersonalizedInsight(any())) + .willReturn(Optional.of(mockResult)); + + // when + VoteResultDetail result = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + + // then + assertThat(result.voted()).isTrue(); + assertThat(result.mySelectedOptionId()).isEqualTo(optionA.getId()); + assertThat(result.aiInsight().available()).isTrue(); + assertThat(result.aiInsight().headline()).isEqualTo("당신은 다수파입니다!"); + assertThat(result.aiInsight().body()).contains("남성 20대"); + } + + @Test + @DisplayName("투표 미참여자는 AI 인사이트를 받지 않는다") + void nonParticipantDoesNotGetAiInsight() { + // given + participate(femaleUser30s, optionA); + // maleUser20s는 참여하지 않음 + + // when + VoteResultDetail result = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + + // then + assertThat(result.voted()).isFalse(); + assertThat(result.aiInsight().available()).isFalse(); + + // AI 호출이 없어야 함 + verify(aiInsightUseCase, never()).generatePersonalizedInsight(any()); + } + + @Test + @DisplayName("다른 성별/연령대 사용자는 다른 인사이트를 받는다") + void differentDemographicsGetDifferentInsights() { + // given + participate(maleUser20s, optionA); + participate(femaleUser30s, optionA); + + AiInsightResult maleInsight = new AiInsightResult("남성 인사이트", "남성용 바디"); + AiInsightResult femaleInsight = new AiInsightResult("여성 인사이트", "여성용 바디"); + + given(aiInsightUseCase.generatePersonalizedInsight(any())) + .willReturn(Optional.of(maleInsight)) + .willReturn(Optional.of(femaleInsight)); + + // when + VoteResultDetail maleResult = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + VoteResultDetail femaleResult = voteResultQueryUseCase.getResult( + vote.getId(), femaleUser30s.getId()); + + // then + assertThat(maleResult.aiInsight().headline()).isEqualTo("남성 인사이트"); + assertThat(femaleResult.aiInsight().headline()).isEqualTo("여성 인사이트"); + + // AI가 2번 호출됨 (다른 캐시 키) + verify(aiInsightUseCase, times(2)).generatePersonalizedInsight(any()); + } + + @Test + @DisplayName("AI 호출 실패 시 인사이트 unavailable 반환") + void aiFailureReturnsUnavailable() { + // given + participate(maleUser20s, optionA); + + given(aiInsightUseCase.generatePersonalizedInsight(any())) + .willReturn(Optional.empty()); + + // when + VoteResultDetail result = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + + // then + assertThat(result.aiInsight().available()).isFalse(); + } + + @Test + @DisplayName("비회원은 AI 인사이트를 받지 않는다") + void guestDoesNotGetAiInsight() { + // given + participate(maleUser20s, optionA); + + // when + VoteResultDetail result = voteResultQueryUseCase.getResult( + vote.getId(), null); + + // then + assertThat(result.insight().locked()).isTrue(); + assertThat(result.aiInsight().available()).isFalse(); + + verify(aiInsightUseCase, never()).generatePersonalizedInsight(any()); + } + } + + @Nested + @DisplayName("캐시 동작 검증") + class CacheTest { + + @Test + @DisplayName("동일 조건 재조회 시 캐시 히트로 AI 재호출 없음") + void cacheHitOnSameCondition() { + // given + participate(maleUser20s, optionA); + + AiInsightResult mockResult = new AiInsightResult("캐시됨", "캐시된 바디"); + given(aiInsightUseCase.generatePersonalizedInsight(any())) + .willReturn(Optional.of(mockResult)); + + // when - 첫 번째 조회 + VoteResultDetail first = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + + // when - 두 번째 조회 (동일 조건) + VoteResultDetail second = voteResultQueryUseCase.getResult( + vote.getId(), maleUser20s.getId()); + + // then + assertThat(first.aiInsight().headline()).isEqualTo("캐시됨"); + assertThat(second.aiInsight().headline()).isEqualTo("캐시됨"); + + // AI는 1번만 호출됨 (두 번째는 캐시 히트) + verify(aiInsightUseCase, times(1)).generatePersonalizedInsight(any()); + } + } +} diff --git a/src/integrationTest/java/com/ject/vs/user/port/UserServiceIntegrationTest.java b/src/integrationTest/java/com/ject/vs/user/port/UserServiceIntegrationTest.java index cfcd20e4..2c6aaa28 100644 --- a/src/integrationTest/java/com/ject/vs/user/port/UserServiceIntegrationTest.java +++ b/src/integrationTest/java/com/ject/vs/user/port/UserServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.ject.vs.user.port; +import com.ject.vs.image.port.ImageService; import com.ject.vs.user.domain.User; import com.ject.vs.user.domain.UserRepository; import org.junit.jupiter.api.DisplayName; @@ -7,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; @@ -18,6 +20,9 @@ @Transactional class UserServiceIntegrationTest { + @MockitoBean + private ImageService imageService; + @Autowired private UserService userService; diff --git a/src/integrationTest/java/com/ject/vs/vote/adapter/web/VoteApiIntegrationTest.java b/src/integrationTest/java/com/ject/vs/vote/adapter/web/VoteApiIntegrationTest.java new file mode 100644 index 00000000..3caf960e --- /dev/null +++ b/src/integrationTest/java/com/ject/vs/vote/adapter/web/VoteApiIntegrationTest.java @@ -0,0 +1,312 @@ +package com.ject.vs.vote.adapter.web; + +import com.ject.vs.image.port.ImageService; +import com.ject.vs.user.domain.User; +import com.ject.vs.user.domain.UserRepository; +import com.ject.vs.vote.domain.Vote; +import com.ject.vs.vote.domain.VoteOption; +import com.ject.vs.vote.domain.VoteOptionRepository; +import com.ject.vs.vote.domain.VoteRepository; +import com.ject.vs.vote.domain.VoteStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("Vote API 통합 테스트") +class VoteApiIntegrationTest { + + @MockitoBean + private ImageService imageService; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private VoteRepository voteRepository; + + @Autowired + private VoteOptionRepository voteOptionRepository; + + @Autowired + private UserRepository userRepository; + + private User testUser; + + @BeforeEach + void setUp() { + // 테스트용 사용자 생성 + testUser = userRepository.save(User.createWithSub("test-sub-" + System.currentTimeMillis())); + } + + private void authenticateAs(Long userId) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, AuthorityUtils.NO_AUTHORITIES); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private void clearAuthentication() { + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("POST /api/votes - 투표 생성") + class CreateVote { + + @Test + @DisplayName("인증된 사용자가 투표 생성 시 DB에 저장된다") + void 인증된_사용자가_투표_생성시_DB에_저장된다() throws Exception { + // given + authenticateAs(testUser.getId()); + + String requestBody = """ + { + "title": "테스트 투표 제목", + "content": "테스트 투표 내용", + "thumbnailUrl": "https://example.com/thumb.png", + "duration": "HOURS_12", + "optionA": "선택지 A", + "optionB": "선택지 B" + } + """; + + // when + MvcResult result = mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.voteId").isNumber()) + .andExpect(jsonPath("$.status").value("ONGOING")) + .andExpect(jsonPath("$.endAt").isNotEmpty()) + .andReturn(); + + // then - DB에 저장 확인 + List votes = voteRepository.findAll(); + assertThat(votes).hasSize(1); + + Vote savedVote = votes.get(0); + assertThat(savedVote.getTitle()).isEqualTo("테스트 투표 제목"); + assertThat(savedVote.getContent()).isEqualTo("테스트 투표 내용"); + assertThat(savedVote.getThumbnailUrl()).isEqualTo("https://example.com/thumb.png"); + assertThat(savedVote.getEndAt()).isNotNull(); + + // VoteOption도 저장 확인 + List options = voteOptionRepository.findByVoteIdOrderByPosition(savedVote.getId()); + assertThat(options).hasSize(2); + assertThat(options.get(0).getLabel()).isEqualTo("선택지 A"); + assertThat(options.get(1).getLabel()).isEqualTo("선택지 B"); + } + + @Test + @DisplayName("몰입형 투표 생성 시 imageUrl도 함께 저장된다") + void 몰입형_투표_생성시_imageUrl도_저장된다() throws Exception { + // given + authenticateAs(testUser.getId()); + + String requestBody = """ + { + "title": "몰입형 투표", + "content": "몰입형 내용", + "thumbnailUrl": "https://example.com/thumb.png", + "imageUrl": "https://example.com/image.png", + "duration": "HOURS_24", + "optionA": "A", + "optionB": "B" + } + """; + + // when + mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()); + + // then + Vote savedVote = voteRepository.findAll().get(0); + assertThat(savedVote.getImageUrl()).isEqualTo("https://example.com/image.png"); + } + + @Test + @DisplayName("인증되지 않은 사용자는 4xx 에러") + void 인증되지_않은_사용자는_4xx() throws Exception { + // given + clearAuthentication(); + + String requestBody = """ + { + "title": "테스트", + "thumbnailUrl": "https://example.com/thumb.png", + "duration": "HOURS_12", + "optionA": "A", + "optionB": "B" + } + """; + + // when & then + mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().is4xxClientError()); + + // DB에 저장되지 않음 + assertThat(voteRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("다양한 duration으로 투표 생성 시 endAt이 정확히 계산된다") + void 다양한_duration으로_투표_생성시_endAt_계산_확인() throws Exception { + // given + authenticateAs(testUser.getId()); + + String[] durations = {"HOURS_12", "HOURS_24"}; + + for (String duration : durations) { + String requestBody = String.format(""" + { + "title": "투표 %s", + "thumbnailUrl": "https://example.com/thumb.png", + "duration": "%s", + "optionA": "A", + "optionB": "B" + } + """, duration, duration); + + // when + mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.endAt").isNotEmpty()); + } + + // then + List votes = voteRepository.findAll(); + assertThat(votes).hasSize(2); + + // 모든 투표의 endAt이 설정되어 있는지 확인 + for (Vote vote : votes) { + assertThat(vote.getEndAt()).isNotNull(); + } + } + } + + @Nested + @DisplayName("GET /api/votes/{voteId} - 투표 상세 조회") + class GetVoteDetail { + + @Test + @DisplayName("투표 상세 조회 시 endAt이 응답에 포함된다") + void 투표_상세_조회시_endAt_포함() throws Exception { + // given - 투표 생성 + authenticateAs(testUser.getId()); + + String createRequest = """ + { + "title": "상세 조회 테스트", + "content": "내용", + "thumbnailUrl": "https://example.com/thumb.png", + "duration": "HOURS_12", + "optionA": "A", + "optionB": "B" + } + """; + + MvcResult createResult = mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(createRequest)) + .andExpect(status().isCreated()) + .andReturn(); + + // voteId 추출 + String responseBody = createResult.getResponse().getContentAsString(); + Long voteId = voteRepository.findAll().get(0).getId(); + + // when & then - 상세 조회 + mockMvc.perform(get("/api/votes/{voteId}", voteId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.voteId").value(voteId)) + .andExpect(jsonPath("$.title").value("상세 조회 테스트")) + .andExpect(jsonPath("$.endAt").isNotEmpty()) + .andExpect(jsonPath("$.status").value("ONGOING")); + } + } + + @Nested + @DisplayName("전체 플로우 테스트") + class FullFlowTest { + + @Test + @DisplayName("투표 생성 → 조회 → DB 저장 전체 플로우") + void 투표_생성_조회_DB_저장_전체_플로우() throws Exception { + // 1. 투표 생성 + authenticateAs(testUser.getId()); + + String createRequest = """ + { + "title": "전체 플로우 테스트", + "content": "테스트 내용입니다", + "thumbnailUrl": "https://example.com/thumb.png", + "duration": "HOURS_24", + "optionA": "찬성", + "optionB": "반대" + } + """; + + mockMvc.perform(post("/api/votes") + .contentType(MediaType.APPLICATION_JSON) + .content(createRequest)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.voteId").isNumber()) + .andExpect(jsonPath("$.status").value("ONGOING")) + .andExpect(jsonPath("$.endAt").isNotEmpty()); + + // 2. DB 저장 확인 + List votes = voteRepository.findAll(); + assertThat(votes).hasSize(1); + + Vote savedVote = votes.get(0); + assertThat(savedVote.getTitle()).isEqualTo("전체 플로우 테스트"); + assertThat(savedVote.getContent()).isEqualTo("테스트 내용입니다"); + assertThat(savedVote.getEndAt()).isNotNull(); + assertThat(savedVote.getStatus()).isEqualTo(VoteStatus.ONGOING); + + // 3. VoteOption 저장 확인 + List options = voteOptionRepository.findByVoteIdOrderByPosition(savedVote.getId()); + assertThat(options).hasSize(2); + assertThat(options.get(0).getLabel()).isEqualTo("찬성"); + assertThat(options.get(1).getLabel()).isEqualTo("반대"); + + // 4. 투표 상세 조회 + mockMvc.perform(get("/api/votes/{voteId}", savedVote.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("전체 플로우 테스트")) + .andExpect(jsonPath("$.endAt").isNotEmpty()) + .andExpect(jsonPath("$.options[0].label").value("찬성")) + .andExpect(jsonPath("$.options[1].label").value("반대")); + } + } +} diff --git a/src/integrationTest/java/com/ject/vs/vote/domain/VoteRepositoryIntegrationTest.java b/src/integrationTest/java/com/ject/vs/vote/domain/VoteRepositoryIntegrationTest.java new file mode 100644 index 00000000..a602a35a --- /dev/null +++ b/src/integrationTest/java/com/ject/vs/vote/domain/VoteRepositoryIntegrationTest.java @@ -0,0 +1,257 @@ +package com.ject.vs.vote.domain; + +import com.ject.vs.config.JpaAuditingConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(JpaAuditingConfig.class) +@DisplayName("Vote 저장/조회 통합 테스트") +class VoteRepositoryIntegrationTest { + + private static final Instant BASE_TIME = Instant.parse("2025-01-01T00:00:00Z"); + private static final Clock FIXED_CLOCK = Clock.fixed(BASE_TIME, ZoneOffset.UTC); + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private VoteRepository voteRepository; + + @Autowired + private VoteOptionRepository voteOptionRepository; + + @Nested + @DisplayName("Vote 저장 테스트") + class VoteSaveTest { + + @Test + @DisplayName("일반형 투표 저장 시 모든 필드가 DB에 정확히 저장된다") + void 일반형_투표_저장_시_모든_필드가_DB에_저장된다() { + // given + Vote vote = Vote.create( + "테스트 제목", + "테스트 내용", + "https://example.com/thumb.png", + null, + Duration.ofHours(24), + FIXED_CLOCK + ); + + // when + Vote savedVote = voteRepository.save(vote); + entityManager.flush(); + entityManager.clear(); + + // then + Vote foundVote = voteRepository.findById(savedVote.getId()).orElseThrow(); + + assertThat(foundVote.getId()).isNotNull(); + assertThat(foundVote.getTitle()).isEqualTo("테스트 제목"); + assertThat(foundVote.getContent()).isEqualTo("테스트 내용"); + assertThat(foundVote.getThumbnailUrl()).isEqualTo("https://example.com/thumb.png"); + assertThat(foundVote.getImageUrl()).isNull(); + assertThat(foundVote.getEndAt()).isEqualTo(BASE_TIME.plus(Duration.ofHours(24))); + assertThat(foundVote.getCreatedAt()).isNotNull(); + assertThat(foundVote.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("몰입형 투표 저장 시 imageUrl이 함께 저장된다") + void 몰입형_투표_저장_시_imageUrl이_함께_저장된다() { + // given + Vote vote = Vote.create( + "몰입형 제목", + "몰입형 내용", + "https://example.com/thumb.png", + "https://example.com/image.png", + Duration.ofHours(48), + FIXED_CLOCK + ); + + // when + Vote savedVote = voteRepository.save(vote); + entityManager.flush(); + entityManager.clear(); + + // then + Vote foundVote = voteRepository.findById(savedVote.getId()).orElseThrow(); + + assertThat(foundVote.getImageUrl()).isEqualTo("https://example.com/image.png"); + assertThat(foundVote.getEndAt()).isEqualTo(BASE_TIME.plus(Duration.ofHours(48))); + } + + @Test + @DisplayName("다양한 duration으로 투표 저장 시 endAt이 정확히 계산되어 저장된다") + void 다양한_duration으로_투표_저장_시_endAt이_정확히_계산된다() { + // given + Vote vote1h = Vote.create("1시간", null, "thumb", null, + Duration.ofHours(1), FIXED_CLOCK); + Vote vote12h = Vote.create("12시간", null, "thumb", null, + Duration.ofHours(12), FIXED_CLOCK); + Vote vote24h = Vote.create("24시간", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK); + + // when + voteRepository.saveAll(List.of(vote1h, vote12h, vote24h)); + entityManager.flush(); + entityManager.clear(); + + // then + List allVotes = voteRepository.findAll(); + + Vote found1h = allVotes.stream().filter(v -> v.getTitle().equals("1시간")).findFirst().orElseThrow(); + Vote found12h = allVotes.stream().filter(v -> v.getTitle().equals("12시간")).findFirst().orElseThrow(); + Vote found24h = allVotes.stream().filter(v -> v.getTitle().equals("24시간")).findFirst().orElseThrow(); + + assertThat(found1h.getEndAt()).isEqualTo(BASE_TIME.plus(Duration.ofHours(1))); + assertThat(found12h.getEndAt()).isEqualTo(BASE_TIME.plus(Duration.ofHours(12))); + assertThat(found24h.getEndAt()).isEqualTo(BASE_TIME.plus(Duration.ofHours(24))); + } + } + + @Nested + @DisplayName("Vote + VoteOption 함께 저장 테스트") + class VoteWithOptionsTest { + + @Test + @DisplayName("투표와 옵션이 함께 저장되고 조회된다") + void 투표와_옵션이_함께_저장되고_조회된다() { + // given + Vote vote = Vote.create("선택 투표", "A vs B", + "thumb", null, Duration.ofHours(24), FIXED_CLOCK); + Vote savedVote = voteRepository.save(vote); + + VoteOption optionA = VoteOption.of(savedVote, "선택지 A", 0); + VoteOption optionB = VoteOption.of(savedVote, "선택지 B", 1); + voteOptionRepository.saveAll(List.of(optionA, optionB)); + + entityManager.flush(); + entityManager.clear(); + + // when + List foundOptions = voteOptionRepository.findByVoteIdOrderByPosition(savedVote.getId()); + + // then + assertThat(foundOptions).hasSize(2); + assertThat(foundOptions.get(0).getLabel()).isEqualTo("선택지 A"); + assertThat(foundOptions.get(0).getPosition()).isEqualTo(0); + assertThat(foundOptions.get(1).getLabel()).isEqualTo("선택지 B"); + assertThat(foundOptions.get(1).getPosition()).isEqualTo(1); + } + } + + @Nested + @DisplayName("진행중/종료된 투표 조회 테스트") + class VoteStatusQueryTest { + + private Vote ongoingVote; + private Vote expiredVote; + + @BeforeEach + void setUp() { + // 진행 중인 투표: endAt = BASE_TIME + 24h + ongoingVote = voteRepository.save( + Vote.create("진행중 투표", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK) + ); + + // 종료된 투표: endAt = BASE_TIME + 1h (조회 시점 BASE_TIME + 2h 기준으로 종료됨) + expiredVote = voteRepository.save( + Vote.create("종료된 투표", null, "thumb", null, + Duration.ofHours(1), FIXED_CLOCK) + ); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("findOngoingVotes는 현재 시각 기준 진행 중인 투표만 반환한다") + void findOngoingVotes는_진행중인_투표만_반환한다() { + // given + Instant queryTime = BASE_TIME.plus(Duration.ofHours(2)); // 1시간짜리는 종료됨 + + // when + List ongoingVotes = voteRepository.findOngoingVotes(queryTime); + + // then + assertThat(ongoingVotes).hasSize(1); + assertThat(ongoingVotes.get(0).getTitle()).isEqualTo("진행중 투표"); + } + + @Test + @DisplayName("findExpiredOngoing은 현재 시각 기준 종료된 투표를 반환한다") + void findExpiredOngoing은_종료된_투표를_반환한다() { + // given + Instant queryTime = BASE_TIME.plus(Duration.ofHours(2)); + + // when + List expiredVotes = voteRepository.findExpiredOngoing(queryTime); + + // then + assertThat(expiredVotes).hasSize(1); + assertThat(expiredVotes.get(0).getTitle()).isEqualTo("종료된 투표"); + } + + @Test + @DisplayName("getStatus는 endAt 기준으로 ONGOING/ENDED를 정확히 반환한다") + void getStatus는_endAt_기준으로_상태를_반환한다() { + // given + Vote foundOngoing = voteRepository.findById(ongoingVote.getId()).orElseThrow(); + Vote foundExpired = voteRepository.findById(expiredVote.getId()).orElseThrow(); + + Clock afterExpiryClock = Clock.fixed(BASE_TIME.plus(Duration.ofHours(2)), ZoneOffset.UTC); + + // when & then + assertThat(foundOngoing.getStatus(afterExpiryClock)).isEqualTo(VoteStatus.ONGOING); + assertThat(foundExpired.getStatus(afterExpiryClock)).isEqualTo(VoteStatus.ENDED); + } + } + + @Nested + @DisplayName("AI Insight 캐싱 테스트") + class AiInsightCacheTest { + + @Test + @DisplayName("AI Insight가 저장되고 조회된다") + void AI_Insight가_저장되고_조회된다() { + // given + Vote vote = voteRepository.save( + Vote.create("AI 분석 투표", null, "thumb", null, + Duration.ofHours(24), FIXED_CLOCK) + ); + vote.cacheAiInsight("AI 분석 헤드라인", "AI 분석 본문 내용"); + voteRepository.save(vote); + + entityManager.flush(); + entityManager.clear(); + + // when + Vote foundVote = voteRepository.findById(vote.getId()).orElseThrow(); + + // then + assertThat(foundVote.hasAiInsight()).isTrue(); + assertThat(foundVote.getAiInsightHeadline()).isEqualTo("AI 분석 헤드라인"); + assertThat(foundVote.getAiInsightBody()).isEqualTo("AI 분석 본문 내용"); + } + } +} diff --git a/src/main/java/com/ject/vs/ai/config/GeminiProperties.java b/src/main/java/com/ject/vs/ai/config/GeminiProperties.java index 23483f64..6f7de91e 100644 --- a/src/main/java/com/ject/vs/ai/config/GeminiProperties.java +++ b/src/main/java/com/ject/vs/ai/config/GeminiProperties.java @@ -11,10 +11,13 @@ public record GeminiProperties( ) { public GeminiProperties { if (location == null || location.isBlank()) { - location = "asia-northeast3"; + // asia-northeast3(서울)은 Gemini 모델 가용성이 없어 NOT_FOUND가 발생한다. + // Gemini 모델이 보장되는 리전을 기본값으로 사용한다. (필요 시 GEMINI_LOCATION으로 override) + location = "us-central1"; } if (model == null || model.isBlank()) { - model = "gemini-1.5-flash"; + // gemini-1.5 retired, 2.0-flash는 프로젝트 접근권 없음(404). 접근 확인된 모델 사용. (필요 시 GEMINI_MODEL로 override) + model = "gemini-2.5-flash"; } } } diff --git a/src/main/java/com/ject/vs/ai/port/AiInsightService.java b/src/main/java/com/ject/vs/ai/port/AiInsightService.java index 01330f4c..ed0f4e26 100644 --- a/src/main/java/com/ject/vs/ai/port/AiInsightService.java +++ b/src/main/java/com/ject/vs/ai/port/AiInsightService.java @@ -42,6 +42,28 @@ public Optional generateVoteInsight(VoteInsightRequest request) } } + @Override + public Optional generatePersonalizedInsight(PersonalizedVoteInsightRequest request) { + if (vertexAI.isEmpty() || !properties.enabled()) { + log.warn("AI insight generation is disabled or Gemini is not configured"); + return Optional.empty(); + } + + try { + String prompt = buildPersonalizedPrompt(request); + + GenerativeModel model = new GenerativeModel(properties.model(), vertexAI.get()); + GenerateContentResponse response = model.generateContent(prompt); + String responseText = ResponseHandler.getText(response); + + return parseResponse(responseText); + + } catch (Exception e) { + log.error("Failed to generate personalized AI insight for vote: {}", request.voteTitle(), e); + return Optional.empty(); + } + } + private String buildPrompt(VoteInsightRequest request) { return String.format(""" 당신은 투표 결과를 분석하는 전문가입니다. 한국어로 친근하고 자연스럽게 분석 결과를 작성해주세요. @@ -72,6 +94,65 @@ private String buildPrompt(VoteInsightRequest request) { ); } + private String buildPersonalizedPrompt(PersonalizedVoteInsightRequest request) { + StringBuilder sb = new StringBuilder(); + sb.append("당신은 투표 결과를 분석하는 전문가입니다. 한국어로 친근하고 자연스럽게 \"당신\"에게 개인화된 분석 결과를 작성해주세요.\n\n"); + + sb.append("[투표 정보]\n"); + sb.append(String.format("투표 주제: %s\n", request.voteTitle())); + sb.append(String.format("선택지 A: \"%s\" - %d명 (%d%%)\n", + request.optionALabel(), request.optionACount(), request.optionARatio())); + sb.append(String.format("선택지 B: \"%s\" - %d명 (%d%%)\n", + request.optionBLabel(), request.optionBCount(), request.optionBRatio())); + sb.append(String.format("총 참여자: %d명\n\n", request.totalParticipants())); + + sb.append("[당신의 선택]\n"); + sb.append(String.format("선택한 옵션: %s\n\n", + request.userSelectedOption() != null ? request.userSelectedOption() : "알 수 없음")); + + sb.append("[당신의 프로필]\n"); + sb.append(String.format("성별: %s\n", + formatGender(request.userGender()))); + sb.append(String.format("연령대: %s\n\n", + request.userAgeGroup() != null ? request.userAgeGroup() : "알 수 없음")); + + sb.append("[그룹 비교]\n"); + if (request.userGender() != null) { + sb.append(String.format("- 같은 성별(%s) 중 %d%%가 당신과 같은 선택\n", + formatGender(request.userGender()), request.sameGenderRatio())); + if (request.sameGenderMajorityOption() != null) { + sb.append(String.format("- %s의 다수 선택: %s\n", + formatGender(request.userGender()), request.sameGenderMajorityOption())); + } + } + if (request.userAgeGroup() != null) { + sb.append(String.format("- 같은 연령대(%s) 중 %d%%가 당신과 같은 선택\n", + request.userAgeGroup(), request.sameAgeGroupRatio())); + if (request.sameAgeGroupMajorityOption() != null) { + sb.append(String.format("- %s의 다수 선택: %s\n", + request.userAgeGroup(), request.sameAgeGroupMajorityOption())); + } + } + + sb.append("\n위 정보를 바탕으로 \"당신\"에게 개인화된 흥미로운 인사이트를 작성해주세요.\n"); + sb.append("2인칭(\"당신\")을 사용하여 친근하게 작성해주세요.\n\n"); + + sb.append("응답은 반드시 다음 형식으로 작성해주세요:\n"); + sb.append("HEADLINE: (한 줄 요약, 50자 이내)\n"); + sb.append("BODY: (상세 분석, 100자 이내)\n"); + + return sb.toString(); + } + + private String formatGender(String gender) { + if (gender == null) return "알 수 없음"; + return switch (gender) { + case "MALE" -> "남성"; + case "FEMALE" -> "여성"; + default -> gender; + }; + } + private Optional parseResponse(String responseText) { if (responseText == null || responseText.isBlank()) { return Optional.empty(); diff --git a/src/main/java/com/ject/vs/ai/port/PersonalizedAiInsightService.java b/src/main/java/com/ject/vs/ai/port/PersonalizedAiInsightService.java new file mode 100644 index 00000000..d32c4f35 --- /dev/null +++ b/src/main/java/com/ject/vs/ai/port/PersonalizedAiInsightService.java @@ -0,0 +1,42 @@ +package com.ject.vs.ai.port; + +import com.github.benmanes.caffeine.cache.Cache; +import com.ject.vs.ai.port.in.AiInsightUseCase; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; +import com.ject.vs.ai.port.in.AiInsightUseCase.PersonalizedVoteInsightRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PersonalizedAiInsightService { + + private final PersonalizedInsightDataCollector dataCollector; + private final AiInsightUseCase aiInsightUseCase; + private final Cache personalizedAiInsightCache; + + public Optional getOrGenerate(Long voteId, Long userId, Long selectedOptionId) { + PersonalizedVoteInsightRequest request = dataCollector.collect(voteId, userId, selectedOptionId); + String cacheKey = request.cacheKey(voteId, userId, selectedOptionId); + + AiInsightResult cached = personalizedAiInsightCache.getIfPresent(cacheKey); + if (cached != null) { + log.debug("Cache hit for personalized AI insight: {}", cacheKey); + return Optional.of(cached); + } + + log.debug("Cache miss for personalized AI insight: {}, generating...", cacheKey); + Optional result = aiInsightUseCase.generatePersonalizedInsight(request); + + result.ifPresent(insight -> { + personalizedAiInsightCache.put(cacheKey, insight); + log.info("Personalized AI insight generated and cached for vote: {}, user: {}", voteId, userId); + }); + + return result; + } +} diff --git a/src/main/java/com/ject/vs/ai/port/PersonalizedInsightDataCollector.java b/src/main/java/com/ject/vs/ai/port/PersonalizedInsightDataCollector.java new file mode 100644 index 00000000..cf34a208 --- /dev/null +++ b/src/main/java/com/ject/vs/ai/port/PersonalizedInsightDataCollector.java @@ -0,0 +1,176 @@ +package com.ject.vs.ai.port; + +import com.ject.vs.ai.port.in.AiInsightUseCase.PersonalizedVoteInsightRequest; +import com.ject.vs.user.domain.Gender; +import com.ject.vs.user.domain.User; +import com.ject.vs.user.domain.UserRepository; +import com.ject.vs.vote.domain.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class PersonalizedInsightDataCollector { + + private final VoteRepository voteRepository; + private final VoteOptionRepository voteOptionRepository; + private final VoteParticipationRepository voteParticipationRepository; + private final UserRepository userRepository; + private final Clock clock; + + public PersonalizedVoteInsightRequest collect(Long voteId, Long userId, Long selectedOptionId) { + Vote vote = voteRepository.findById(voteId).orElseThrow(); + List options = voteOptionRepository.findByVoteIdOrderByPosition(voteId); + + VoteOption optionA = options.stream().filter(o -> o.getPosition() == 0).findFirst().orElse(null); + VoteOption optionB = options.stream().filter(o -> o.getPosition() == 1).findFirst().orElse(null); + + long total = voteParticipationRepository.countByVoteId(voteId); + + long optionACount = optionA != null + ? voteParticipationRepository.countByVoteIdAndOptionId(voteId, optionA.getId()) : 0; + long optionBCount = optionB != null + ? voteParticipationRepository.countByVoteIdAndOptionId(voteId, optionB.getId()) : 0; + + int optionARatio = total == 0 ? 0 : (int) Math.round(optionACount * 100.0 / total); + int optionBRatio = total == 0 ? 0 : 100 - optionARatio; + + List genderCounts = voteParticipationRepository.findGenderDistributionByVote(voteId); + long femaleCount = genderCounts.stream() + .filter(gc -> Gender.FEMALE == gc.gender()) + .mapToLong(GenderCount::count).sum(); + int femaleRatio = total == 0 ? 0 : (int) Math.round(femaleCount * 100.0 / total); + int maleRatio = 100 - femaleRatio; + + String majorityAgeGroup = findMajorityAgeGroup(voteId); + + User user = userRepository.findById(userId).orElse(null); + String userGender = user != null && user.getGender() != null ? user.getGender().name() : null; + AgeGroup userAgeGroup = user != null && user.getBirthYear() != null + ? AgeGroup.fromBirthYear(user.getBirthYear(), clock) : null; + + String userSelectedOptionLabel = findOptionLabel(options, selectedOptionId); + + int sameGenderRatio = 0; + String sameGenderMajorityOption = null; + if (user != null && user.getGender() != null) { + sameGenderRatio = calculateSameGenderRatio(voteId, selectedOptionId, user.getGender()); + sameGenderMajorityOption = findMajorityOptionByGender(voteId, user.getGender(), options); + } + + int sameAgeGroupRatio = 0; + String sameAgeGroupMajorityOption = null; + if (userAgeGroup != null) { + sameAgeGroupRatio = calculateSameAgeGroupRatio(voteId, selectedOptionId, userAgeGroup); + sameAgeGroupMajorityOption = findMajorityOptionByAgeGroup(voteId, userAgeGroup, options); + } + + return new PersonalizedVoteInsightRequest( + vote.getTitle(), + optionA != null ? optionA.getLabel() : "A", + optionACount, + optionARatio, + optionB != null ? optionB.getLabel() : "B", + optionBCount, + optionBRatio, + total, + femaleRatio, + maleRatio, + majorityAgeGroup, + userSelectedOptionLabel, + userGender, + userAgeGroup != null ? userAgeGroup.getLabel() : null, + sameGenderRatio, + sameAgeGroupRatio, + sameGenderMajorityOption, + sameAgeGroupMajorityOption + ); + } + + private String findOptionLabel(List options, Long optionId) { + return options.stream() + .filter(o -> o.getId().equals(optionId)) + .findFirst() + .map(VoteOption::getLabel) + .orElse(null); + } + + private String findMajorityAgeGroup(Long voteId) { + List userIds = voteParticipationRepository.findAllUserIdsByVoteId(voteId); + List users = userRepository.findAllById(userIds); + + Map groupCounts = users.stream() + .filter(u -> u.getBirthYear() != null) + .collect(Collectors.groupingBy( + u -> AgeGroup.fromBirthYear(u.getBirthYear(), clock), + Collectors.counting())); + + return groupCounts.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(e -> e.getKey().getLabel()) + .orElse(null); + } + + private int calculateSameGenderRatio(Long voteId, Long selectedOptionId, Gender gender) { + long sameGenderSelectedOption = voteParticipationRepository + .countByVoteIdAndOptionIdAndGender(voteId, selectedOptionId, gender); + long totalSameGender = voteParticipationRepository.countByVoteIdAndGender(voteId, gender); + + return totalSameGender == 0 ? 0 : (int) Math.round(sameGenderSelectedOption * 100.0 / totalSameGender); + } + + private String findMajorityOptionByGender(Long voteId, Gender gender, List options) { + List optionCounts = voteParticipationRepository.findOptionCountsByVoteIdAndGender(voteId, gender); + if (optionCounts.isEmpty()) { + return null; + } + + Long majorityOptionId = (Long) optionCounts.get(0)[0]; + return findOptionLabel(options, majorityOptionId); + } + + private int calculateSameAgeGroupRatio(Long voteId, Long selectedOptionId, AgeGroup ageGroup) { + List userIds = voteParticipationRepository.findUserIdsByVoteIdAndOptionId(voteId, selectedOptionId); + List users = userRepository.findAllById(userIds); + + long sameAgeGroupCount = users.stream() + .filter(u -> u.getBirthYear() != null) + .filter(u -> AgeGroup.fromBirthYear(u.getBirthYear(), clock) == ageGroup) + .count(); + + List allUserIds = voteParticipationRepository.findAllUserIdsByVoteId(voteId); + List allUsers = userRepository.findAllById(allUserIds); + + long totalSameAgeGroup = allUsers.stream() + .filter(u -> u.getBirthYear() != null) + .filter(u -> AgeGroup.fromBirthYear(u.getBirthYear(), clock) == ageGroup) + .count(); + + return totalSameAgeGroup == 0 ? 0 : (int) Math.round(sameAgeGroupCount * 100.0 / totalSameAgeGroup); + } + + private String findMajorityOptionByAgeGroup(Long voteId, AgeGroup ageGroup, List options) { + List allUserIds = voteParticipationRepository.findAllUserIdsByVoteId(voteId); + List allUsers = userRepository.findAllById(allUserIds); + + Map> usersByOption = allUsers.stream() + .filter(u -> u.getBirthYear() != null) + .filter(u -> AgeGroup.fromBirthYear(u.getBirthYear(), clock) == ageGroup) + .collect(Collectors.groupingBy(u -> { + return voteParticipationRepository.findByVoteIdAndUserId(voteId, u.getId()) + .map(VoteParticipation::getOptionId) + .orElse(null); + })); + + return usersByOption.entrySet().stream() + .filter(e -> e.getKey() != null) + .max(Map.Entry.comparingByValue((a, b) -> Integer.compare(a.size(), b.size()))) + .map(e -> findOptionLabel(options, e.getKey())) + .orElse(null); + } +} diff --git a/src/main/java/com/ject/vs/ai/port/in/AiInsightUseCase.java b/src/main/java/com/ject/vs/ai/port/in/AiInsightUseCase.java index 722e1149..78e4321a 100644 --- a/src/main/java/com/ject/vs/ai/port/in/AiInsightUseCase.java +++ b/src/main/java/com/ject/vs/ai/port/in/AiInsightUseCase.java @@ -6,6 +6,8 @@ public interface AiInsightUseCase { Optional generateVoteInsight(VoteInsightRequest request); + Optional generatePersonalizedInsight(PersonalizedVoteInsightRequest request); + record VoteInsightRequest( String voteTitle, String optionALabel, @@ -21,6 +23,32 @@ record VoteInsightRequest( ) { } + record PersonalizedVoteInsightRequest( + String voteTitle, + String optionALabel, + long optionACount, + int optionARatio, + String optionBLabel, + long optionBCount, + int optionBRatio, + long totalParticipants, + int femaleRatio, + int maleRatio, + String majorityAgeGroup, + String userSelectedOption, + String userGender, + String userAgeGroup, + int sameGenderRatio, + int sameAgeGroupRatio, + String sameGenderMajorityOption, + String sameAgeGroupMajorityOption + ) { + public String cacheKey(Long voteId, Long userId, Long selectedOptionId) { + return String.format("%d:%d:%d:%s:%s", + voteId, userId, selectedOptionId, userGender, userAgeGroup); + } + } + record AiInsightResult( String headline, String body diff --git a/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java b/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java index 8e6721cb..b1fb6902 100644 --- a/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java +++ b/src/main/java/com/ject/vs/chat/adapter/event/ChatMessageEventListener.java @@ -36,7 +36,7 @@ public void handle(ChatMessageSentEvent event) { User sender = userQueryUseCase.getUser(message.getSenderId()); VoteOptionCode voteOptionCode = - voteQueryUseCase.getSelectedOption(message.getVoteId(), message.getSenderId()).getCode(); + voteQueryUseCase.findSelectedOptionCode(message.getVoteId(), message.getSenderId()).orElse(null); MessageResult messageResult = new MessageResult( message.getId(), diff --git a/src/main/java/com/ject/vs/chat/port/ChatService.java b/src/main/java/com/ject/vs/chat/port/ChatService.java index bbf1f2bc..d031be35 100644 --- a/src/main/java/com/ject/vs/chat/port/ChatService.java +++ b/src/main/java/com/ject/vs/chat/port/ChatService.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Comparator; import java.util.List; @Service @@ -49,7 +50,7 @@ public MessageResult sendMessage(SendMessageCommand command) { ChatMessage saved = chatMessageRepository.save(message); User sender = userQueryUseCase.getUser(command.senderId()); VoteOptionCode voteOptionCode = - voteQueryUseCase.getSelectedOption(command.voteId(), command.senderId()).getCode(); + voteQueryUseCase.findSelectedOptionCode(command.voteId(), command.senderId()).orElse(null); return new MessageResult( saved.getId(), @@ -98,6 +99,9 @@ public List getChatList(Long userId, VoteStatus status) { unreadCount ); }) + .sorted(Comparator.comparing( + ChatListItemResult::lastMessageAt, + Comparator.nullsLast(Comparator.reverseOrder()))) .toList(); } @@ -137,7 +141,7 @@ public MessagePageResult getMessages(Long voteId, Long userId, Long cursor, int .map(msg -> { User sender = userQueryUseCase.getUser(msg.getSenderId()); VoteOptionCode voteOptionCode = - voteQueryUseCase.getSelectedOption(voteId, msg.getSenderId()).getCode(); + voteQueryUseCase.findSelectedOptionCode(voteId, msg.getSenderId()).orElse(null); return new MessageResult( msg.getId(), diff --git a/src/main/java/com/ject/vs/config/CacheConfig.java b/src/main/java/com/ject/vs/config/CacheConfig.java new file mode 100644 index 00000000..f06edf54 --- /dev/null +++ b/src/main/java/com/ject/vs/config/CacheConfig.java @@ -0,0 +1,21 @@ +package com.ject.vs.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +public class CacheConfig { + + @Bean + public Cache personalizedAiInsightCache() { + return Caffeine.newBuilder() + .maximumSize(1000) + .expireAfterWrite(Duration.ofHours(24)) + .build(); + } +} diff --git a/src/main/java/com/ject/vs/config/OpenApiConfig.java b/src/main/java/com/ject/vs/config/OpenApiConfig.java index 5ad7d419..2081fd96 100644 --- a/src/main/java/com/ject/vs/config/OpenApiConfig.java +++ b/src/main/java/com/ject/vs/config/OpenApiConfig.java @@ -5,16 +5,23 @@ import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; + @Configuration public class OpenApiConfig { + @Value("${springdoc.server-url:}") + private String serverUrl; + @Bean public OpenAPI openAPI() { String securitySchemeName = "bearerAuth"; - return new OpenAPI() + OpenAPI openAPI = new OpenAPI() .info(new Info() .title("VS Server API") .description("JECT 4기 2팀 VS Server API") @@ -26,5 +33,11 @@ public OpenAPI openAPI() { .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))); + + if (serverUrl != null && !serverUrl.isBlank()) { + openAPI.servers(List.of(new Server().url(serverUrl).description("API Server"))); + } + + return openAPI; } } diff --git a/src/main/java/com/ject/vs/user/adapter/web/UserController.java b/src/main/java/com/ject/vs/user/adapter/web/UserController.java index 4187237d..e168bda7 100644 --- a/src/main/java/com/ject/vs/user/adapter/web/UserController.java +++ b/src/main/java/com/ject/vs/user/adapter/web/UserController.java @@ -41,6 +41,11 @@ public ResponseEntity isUniqueNickname(@AuthenticationPri return ResponseEntity.ok(response); } + @PostMapping("/nickname/slang") + public ResponseEntity checkNicknameSlang(@AuthenticationPrincipal Long userId, @RequestBody UserNicknameRec nickname) { + return ResponseEntity.ok(userService.checkNicknameSlang(nickname.nickname(), userId)); + } + @Operation(summary = "닉네임 추천", description = "사용 가능한 랜덤 닉네임을 추천합니다.") @GetMapping("/nickname/suggest") public ResponseEntity suggestNickname(@AuthenticationPrincipal Long userId) { diff --git a/src/main/java/com/ject/vs/user/domain/User.java b/src/main/java/com/ject/vs/user/domain/User.java index 6b200b4b..35a6585d 100644 --- a/src/main/java/com/ject/vs/user/domain/User.java +++ b/src/main/java/com/ject/vs/user/domain/User.java @@ -63,6 +63,10 @@ public void initializeDefault(String email, Year birthYear, Gender gender, Strin this.nickname = nickname; } + public static void modifyImageColor(User user, ImageColor imageColor) { + user.imageColor = imageColor; + } + public static void modifyAccount(User user, String nickname, ImageColor imageColor) { user.nickname = nickname; user.imageColor = imageColor; diff --git a/src/main/java/com/ject/vs/user/exception/UserErrorCode.java b/src/main/java/com/ject/vs/user/exception/UserErrorCode.java index e37f4162..ca4eead4 100644 --- a/src/main/java/com/ject/vs/user/exception/UserErrorCode.java +++ b/src/main/java/com/ject/vs/user/exception/UserErrorCode.java @@ -9,7 +9,8 @@ public enum UserErrorCode implements ErrorCode { USER_NOT_FOUND("E400000", "사용자 정보가 없습니다.", 404), - USER_NOT_REGISTER("E400001", "등록되지 않은 사용자입니다.", 404); + USER_NOT_REGISTER("E400001", "등록되지 않은 사용자입니다.", 404), + USER_NICKNAME_DUPLICATE("E400002", "중복된 닉네임입니다.", 404); private final String code; private final String message; diff --git a/src/main/java/com/ject/vs/user/port/UserService.java b/src/main/java/com/ject/vs/user/port/UserService.java index 441a486d..a7e8fd85 100644 --- a/src/main/java/com/ject/vs/user/port/UserService.java +++ b/src/main/java/com/ject/vs/user/port/UserService.java @@ -29,16 +29,18 @@ public User findOrCreate(String email) { .orElseGet(() -> userRepository.save(User.createWithEmail(email))); } - public User findByEmail(String email) { - return userRepository.findByEmail(email) + public NicknameCheckResponse checkNickname(String nickName, Long userId) { + userRepository.findById(userId) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + + return new NicknameCheckResponse(userRepository.isNicknameAvailable(nickName)); } - public NicknameCheckResponse checkNickname(String nickName, Long userId) { + public NicknameCheckResponse checkNicknameSlang(String nickName, Long userId) { userRepository.findById(userId) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); - return new NicknameCheckResponse(userRepository.isNicknameAvailable(nickName)); + return new NicknameCheckResponse(!wordService.containSlang(nickName)); } public UserProfileResponse setupAdditionalInfo(UserExtraInfo userInfo, Long userId) { @@ -80,6 +82,15 @@ public UserMyPageResponse modifyInfo(Long userId, UserModifyInfoRequest req) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + if(req.nickname().equals(user.getNickname())) { + User.modifyImageColor(user, req.imageColor()); + + return new UserMyPageResponse(user.getEmail(), user.getNickname(), user.getImageColor()); + } + else if(!userRepository.isNicknameAvailable(req.nickname())) { + throw new BusinessException(UserErrorCode.USER_NICKNAME_DUPLICATE); + } + User.modifyAccount(user, req.nickname(), req.imageColor()); return new UserMyPageResponse(user.getEmail(), user.getNickname(), user.getImageColor()); diff --git a/src/main/java/com/ject/vs/user/port/WordService.java b/src/main/java/com/ject/vs/user/port/WordService.java index 241ca751..a8b3700d 100644 --- a/src/main/java/com/ject/vs/user/port/WordService.java +++ b/src/main/java/com/ject/vs/user/port/WordService.java @@ -1,6 +1,7 @@ package com.ject.vs.user.port; import com.ject.vs.user.domain.UserRepository; +import com.ject.vs.util.SlangFilter; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,12 +29,14 @@ public class WordService { private List words2 = new ArrayList<>(); private List words3 = new ArrayList<>(); + private List slang = new ArrayList<>(); @PostConstruct public void init() { try { words2 = loadWords("classpath:data/ko_words_2.txt"); words3 = loadWords("classpath:data/ko_words_3.txt"); + slang = loadWords("classpath:data/slang.txt"); } catch (IOException e) { log.error("단어 로딩 실패", e); } @@ -77,4 +80,8 @@ public String generateNickname() { return ret; } + + public boolean containSlang(String input) { + return SlangFilter.containsSlang(input, slang); + } } diff --git a/src/main/java/com/ject/vs/util/SlangFilter.java b/src/main/java/com/ject/vs/util/SlangFilter.java new file mode 100644 index 00000000..9d1ca1e5 --- /dev/null +++ b/src/main/java/com/ject/vs/util/SlangFilter.java @@ -0,0 +1,49 @@ +package com.ject.vs.util; + +import java.util.List; + +public class SlangFilter { + private static int[] buildFailure(char[] pattern) { + int m = pattern.length; + int[] fail = new int[m]; + fail[0] = 0; + int j = 0; + + for(int i = 1; i < m; i++) { + while(j > 0 && pattern[i] != pattern[j]) { + j = fail[j-1]; + } + if(pattern[i] == pattern[j]) j++; + fail[i] = j; + } + return fail; + } + + private static boolean kmpContains(char[] text, char[] pattern) { + int n = text.length; + int m = pattern.length; + if(m == 0) return false; + + int[] fail = buildFailure(pattern); + int j = 0; + + for(int i = 0; i < n; i++) { + while(j > 0 && text[i] != pattern[j]) { + j = fail[j-1]; + } + if(text[i] == pattern[j]) j++; + if(j == m) return true; + } + return false; + } + + public static boolean containsSlang(String input, List slangList) { + if(input == null || input.isBlank()) return false; + char[] text = input.toCharArray(); + + for(String slang : slangList) { + if(kmpContains(text, slang.toCharArray())) return true; + } + return false; + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/handler/VoteAiInsightHandler.java b/src/main/java/com/ject/vs/vote/adapter/handler/VoteAiInsightHandler.java index 89513286..8f887c10 100644 --- a/src/main/java/com/ject/vs/vote/adapter/handler/VoteAiInsightHandler.java +++ b/src/main/java/com/ject/vs/vote/adapter/handler/VoteAiInsightHandler.java @@ -10,19 +10,25 @@ import com.ject.vs.vote.event.VoteEndedEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.Clock; -import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +/** + * @deprecated 이 핸들러는 기존 투표 종료 시 1회 생성 방식에서 실시간 개인화 인사이트 생성으로 변경되어 비활성화되었습니다. + * 새로운 구현은 {@link com.ject.vs.ai.port.PersonalizedAiInsightService}를 참조하세요. + */ +@Deprecated @Component +@ConditionalOnProperty(name = "app.ai.legacy-insight-handler.enabled", havingValue = "true", matchIfMissing = false) @RequiredArgsConstructor @Slf4j public class VoteAiInsightHandler { diff --git a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java index c5759266..c485fc42 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/ImmersiveVoteController.java @@ -3,6 +3,8 @@ import com.ject.vs.config.AnonymousId; import com.ject.vs.vote.adapter.web.dto.ImmersiveFeedResponse; import com.ject.vs.vote.adapter.web.dto.ImmersiveLiveResponse; +import com.ject.vs.vote.adapter.web.dto.ImmersiveNextRequest; +import com.ject.vs.vote.adapter.web.dto.ImmersiveNextResponse; import com.ject.vs.vote.adapter.web.dto.ImmersiveParticipateResponse; import com.ject.vs.vote.adapter.web.dto.ParticipateRequest; import com.ject.vs.vote.adapter.web.dto.ShareLinkResponse; @@ -63,4 +65,15 @@ public ImmersiveLiveResponse getLive(@PathVariable Long voteId) { public ShareLinkResponse getShareLink(@PathVariable Long voteId) { return ShareLinkResponse.from(voteResultQueryUseCase.getShareLink(voteId)); } + + @Operation(summary = "랜덤 다음 투표 조회", description = "excludeIds를 제외한 진행 중인 투표를 랜덤으로 조회합니다. 모든 투표 소진 시 빈 배열 반환 → 클라이언트에서 excludeIds 초기화 후 재요청 (무한 순환)") + @PostMapping("/next") + public ImmersiveNextResponse getNextRandom( + @RequestBody @Valid ImmersiveNextRequest request, + @AuthenticationPrincipal Long userId, + @Parameter(hidden = true) @AnonymousId String anonymousId) { + return ImmersiveNextResponse.from( + immersiveVoteQueryUseCase.getNextRandom(request.excludeIds(), request.size(), userId, anonymousId) + ); + } } diff --git a/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java index 657203ef..d9e36ab8 100644 --- a/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java +++ b/src/main/java/com/ject/vs/vote/adapter/web/VoteController.java @@ -15,9 +15,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "일반형 투표", description = "일반형 투표 관련 API") @RestController @@ -39,6 +41,28 @@ public VoteCreateResponse create( return VoteCreateResponse.from(voteCommandUseCase.create(request.toCommand())); } + @Operation(summary = "투표 생성 (이미지 포함)", description = "이미지 파일과 함께 투표를 생성합니다. 서버에서 S3 업로드를 처리합니다.") + @PostMapping(value = "/with-images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public VoteCreateResponse createWithImages( + @AuthenticationPrincipal Long userId, + @RequestParam("title") String title, + @RequestParam(value = "content", required = false) String content, + @RequestParam("thumbnailFile") MultipartFile thumbnailFile, + @RequestParam(value = "imageFile", required = false) MultipartFile imageFile, + @RequestParam("duration") String duration, + @RequestParam("optionA") String optionA, + @RequestParam("optionB") String optionB) { + if (userId == null) throw new UnauthorizedException(); + + var command = new VoteCommandUseCase.VoteCreateWithImagesCommand( + title, content, thumbnailFile, imageFile, + com.ject.vs.vote.domain.VoteDuration.valueOf(duration), + optionA, optionB + ); + return VoteCreateResponse.from(voteCommandUseCase.createWithImages(command)); + } + @Operation(summary = "투표 상세 조회", description = "투표 상세 정보를 조회합니다. 투표 전에는 결과(voteCount/ratio)가 null로 응답됩니다.") @GetMapping("/{voteId}") public VoteDetailResponse getDetail( diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java new file mode 100644 index 00000000..dc67b6da --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextRequest.java @@ -0,0 +1,20 @@ +package com.ject.vs.vote.adapter.web.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.util.List; + +public record ImmersiveNextRequest( + List excludeIds, + + @Min(1) + @Max(50) + Integer size +) { + public ImmersiveNextRequest { + if (size == null) { + size = 10; + } + } +} diff --git a/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java new file mode 100644 index 00000000..bbf0f10b --- /dev/null +++ b/src/main/java/com/ject/vs/vote/adapter/web/dto/ImmersiveNextResponse.java @@ -0,0 +1,78 @@ +package com.ject.vs.vote.adapter.web.dto; + +import com.ject.vs.vote.domain.VoteEmoji; +import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveNextResult; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; + +public record ImmersiveNextResponse(List items) { + + public record VoteItem( + Long voteId, + String title, + String content, + String imageUrl, + OffsetDateTime endAt, + List options, + MyVote myVote, + EmojiSummary emojiSummary, + String myEmoji, + int commentCount, + int currentViewerCount + ) { + } + + public record OptionItem(Long optionId, String label, Long voteCount, Integer ratio) { + } + + public record MyVote(boolean voted, Long selectedOptionId) { + } + + public record EmojiSummary(long LIKE, long SAD, long ANGRY, long WOW, long total) { + public static EmojiSummary from(Map map, long total) { + return new EmojiSummary( + map.getOrDefault(VoteEmoji.LIKE, 0L), + map.getOrDefault(VoteEmoji.SAD, 0L), + map.getOrDefault(VoteEmoji.ANGRY, 0L), + map.getOrDefault(VoteEmoji.WOW, 0L), + total + ); + } + } + + private static OffsetDateTime toKst(Instant instant) { + return instant.atOffset(ZoneOffset.ofHours(9)); + } + + public static ImmersiveNextResponse from(ImmersiveNextResult result) { + List items = result.items().stream() + .map(i -> { + List options = i.options().stream() + .map(o -> new OptionItem(o.optionId(), o.label(), o.voteCount(), o.ratio())) + .toList(); + MyVote myVote = new MyVote(i.voted(), i.mySelectedOptionId()); + EmojiSummary emojiSummary = EmojiSummary.from(i.emojiSummary(), i.emojiTotal()); + String myEmoji = i.myEmoji() != null ? i.myEmoji().name() : null; + + return new VoteItem( + i.voteId(), + i.title(), + i.content(), + i.imageUrl(), + toKst(i.endAt()), + options, + myVote, + emojiSummary, + myEmoji, + i.commentCount(), + i.currentViewerCount() + ); + }) + .toList(); + return new ImmersiveNextResponse(items); + } +} diff --git a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java index 87977218..bd54b40c 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteParticipationRepository.java @@ -64,4 +64,55 @@ public interface VoteParticipationRepository extends JpaRepository findOptionCountsByVoteIdAndGender( + @Param("voteId") Long voteId, + @Param("gender") com.ject.vs.user.domain.Gender gender); + + @Query(""" + SELECT p.optionId, COUNT(p) as cnt + FROM VoteParticipation p, com.ject.vs.user.domain.User u + WHERE p.userId = u.id + AND p.voteId = :voteId + AND u.birthYear IS NOT NULL + AND p.userId IS NOT NULL + GROUP BY p.optionId + """) + List findOptionCountsByVoteId(@Param("voteId") Long voteId); } diff --git a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java index c0f7b60f..83b2c040 100644 --- a/src/main/java/com/ject/vs/vote/domain/VoteRepository.java +++ b/src/main/java/com/ject/vs/vote/domain/VoteRepository.java @@ -149,7 +149,7 @@ Slice findFirstPageForHomeByEndingSoon( @Query(""" SELECT v FROM Vote v WHERE v.endAt > :now - AND (v.endAt > :lastEndAt + AND (v.endAt > :lastEndAt OR (v.endAt = :lastEndAt AND v.id > :lastId)) ORDER BY v.endAt ASC, v.id ASC """) @@ -159,4 +159,40 @@ Slice findForHomeByEndingSoonWithKeyset( @Param("now") Instant now, Pageable pageable ); + + // ===== 몰입형 투표 랜덤 조회 ===== + + /** + * 진행 중인 투표 중 excludeIds를 제외하고 랜덤으로 조회 + */ + @Query(""" + SELECT v FROM Vote v + WHERE v.endAt > :now + AND v.id NOT IN :excludeIds + ORDER BY FUNCTION('RANDOM') + """) + Slice findRandomExcluding( + @Param("now") Instant now, + @Param("excludeIds") List excludeIds, + Pageable pageable + ); + + /** + * 진행 중인 투표 랜덤 조회 (excludeIds 없는 첫 조회용) + */ + @Query(""" + SELECT v FROM Vote v + WHERE v.endAt > :now + ORDER BY FUNCTION('RANDOM') + """) + Slice findRandom( + @Param("now") Instant now, + Pageable pageable + ); + + /** + * 진행 중인 투표 총 개수 조회 (무한 순환 판단용) + */ + @Query("SELECT COUNT(v) FROM Vote v WHERE v.endAt > :now") + long countOngoing(@Param("now") Instant now); } diff --git a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java index 12a6efd6..53fc114f 100644 --- a/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/ImmersiveVoteQueryService.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.chat.domain.ChatMessageRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.VoteNotFoundException; import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase; @@ -26,6 +27,7 @@ public class ImmersiveVoteQueryService implements ImmersiveVoteQueryUseCase { private final VoteOptionRepository voteOptionRepository; private final VoteParticipationRepository voteParticipationRepository; private final VoteEmojiReactionRepository emojiReactionRepository; + private final ChatMessageRepository chatMessageRepository; private final Clock clock; @Override @@ -87,6 +89,25 @@ public ImmersiveLiveResult getLive(Long voteId) { return new ImmersiveLiveResult(liveOptions, 0, (int) total); } + @Override + public ImmersiveNextResult getNextRandom(List excludeIds, int size, Long userId, String anonymousId) { + Instant now = Instant.now(clock); + PageRequest pageable = PageRequest.of(0, size); + + Slice slice; + if (excludeIds == null || excludeIds.isEmpty()) { + slice = voteRepository.findRandom(now, pageable); + } else { + slice = voteRepository.findRandomExcluding(now, excludeIds, pageable); + } + + List items = slice.getContent().stream() + .map(v -> toFeedItem(v, userId, anonymousId)) + .toList(); + + return new ImmersiveNextResult(items); + } + private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) { Long voteId = vote.getId(); long total = voteParticipationRepository.countByVoteId(voteId); @@ -136,11 +157,16 @@ private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) .orElse(null); } + int commentCount = (int) chatMessageRepository.countByVoteId(voteId); + + // imageFile 없이 생성된 투표는 imageUrl이 null이므로 thumbnailUrl로 폴백한다. + String imageUrl = vote.getImageUrl() != null ? vote.getImageUrl() : vote.getThumbnailUrl(); + return new ImmersiveFeedItem( voteId, vote.getTitle(), vote.getContent(), - vote.getImageUrl(), + imageUrl, vote.getEndAt(), options, voted, @@ -148,7 +174,7 @@ private ImmersiveFeedItem toFeedItem(Vote vote, Long userId, String anonymousId) emojiSummary, emojiTotal, myEmoji, - 0, // TODO: commentCount + commentCount, 0 // TODO: Redis viewer count ); } diff --git a/src/main/java/com/ject/vs/vote/port/VoteCommandService.java b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java index be4235ed..ed23a5f9 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteCommandService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteCommandService.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.image.port.ImageService; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.InvalidOptionException; import com.ject.vs.vote.exception.VoteEndedException; @@ -22,6 +23,7 @@ public class VoteCommandService implements VoteCommandUseCase { private final VoteOptionRepository voteOptionRepository; private final VoteParticipationRepository voteParticipationRepository; private final GuestFreeVoteService guestFreeVoteService; + private final Optional imageService; private final Clock clock; @Override @@ -38,6 +40,27 @@ public VoteCreateResult create(VoteCreateCommand cmd) { return VoteCreateResult.from(saved, clock); } + @Override + public VoteCreateResult createWithImages(VoteCreateWithImagesCommand cmd) { + ImageService service = imageService.orElseThrow(() -> + new IllegalStateException("ImageService is not available. S3 configuration may be missing.")); + String thumbnailUrl = service.upload(cmd.thumbnailFile()); + String imageUrl = cmd.imageFile() != null && !cmd.imageFile().isEmpty() + ? service.upload(cmd.imageFile()) + : null; + + Vote vote = Vote.create( + cmd.title(), cmd.content(), + thumbnailUrl, imageUrl, + cmd.duration().getValue(), + clock + ); + Vote saved = voteRepository.save(vote); + voteOptionRepository.save(VoteOption.of(saved, cmd.optionA(), 0)); + voteOptionRepository.save(VoteOption.of(saved, cmd.optionB(), 1)); + return VoteCreateResult.from(saved, clock); + } + @Override public ParticipateResult participateAsMember(Long voteId, Long userId, Long optionId) { loadOngoingVote(voteId); diff --git a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java index 5f3fb1fc..220fd25a 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteDetailQueryService.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.chat.domain.ChatMessageRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.VoteNotFoundException; import com.ject.vs.vote.port.in.VoteCommandUseCase.OptionResult; @@ -23,6 +24,7 @@ public class VoteDetailQueryService { private final VoteOptionRepository voteOptionRepository; private final VoteParticipationRepository voteParticipationRepository; private final VoteEmojiReactionRepository emojiReactionRepository; + private final ChatMessageRepository chatMessageRepository; private final Clock clock; public VoteDetailResult getDetail(Long voteId, Long userId, String anonymousId) { @@ -69,10 +71,12 @@ public VoteDetailResult getDetail(Long voteId, Long userId, String anonymousId) boolean voted = mySelectedOptionId != null; + int commentCount = (int) chatMessageRepository.countByVoteId(voteId); + return new VoteDetailResult( vote.getId(), vote.getTitle(), vote.getCreatedAt(), vote.getContent(), vote.getThumbnailUrl(), vote.getImageUrl(), status, vote.getEndAt(), - (int) total, optionResults, voted, mySelectedOptionId, emojiSummary, myEmoji, 0 + (int) total, optionResults, voted, mySelectedOptionId, emojiSummary, myEmoji, commentCount ); } diff --git a/src/main/java/com/ject/vs/vote/port/VoteQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java index 69f61c18..908eaf52 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteQueryService.java @@ -74,6 +74,11 @@ public VoteOption getSelectedOption(Long voteId, Long userId) { return vote.getOption(selectedOptionId.get()); } + @Override + public Optional findSelectedOptionCode(Long voteId, Long userId) { + return getSelectedOptionId(voteId, userId).map(optionId -> getSelectedOption(voteId, userId).getCode()); + } + @Override public int getParticipantCount(Long voteId) { return (int) voteParticipationRepository.countByVoteId(voteId); diff --git a/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java index 88e911ee..f34cc92a 100644 --- a/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java +++ b/src/main/java/com/ject/vs/vote/port/VoteResultQueryService.java @@ -1,5 +1,7 @@ package com.ject.vs.vote.port; +import com.ject.vs.ai.port.PersonalizedAiInsightService; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; import com.ject.vs.user.domain.Gender; import com.ject.vs.user.domain.User; import com.ject.vs.user.domain.UserRepository; @@ -27,6 +29,7 @@ public class VoteResultQueryService implements VoteResultQueryUseCase { private final VoteOptionRepository voteOptionRepository; private final VoteParticipationRepository voteParticipationRepository; private final UserRepository userRepository; + private final PersonalizedAiInsightService personalizedAiInsightService; private final Clock clock; @Override @@ -62,9 +65,7 @@ public VoteResultDetail getResult(Long voteId, Long userId) { if (myParticipation.isPresent()) { insight = buildMySelectionInsight(voteId, mySelectedOptionId, userId); - aiInsight = vote.hasAiInsight() - ? AiInsightView.of(vote.getAiInsightHeadline(), vote.getAiInsightBody()) - : AiInsightView.unavailable(); + aiInsight = generatePersonalizedAiInsight(voteId, userId, mySelectedOptionId); } else { insight = buildTotalInsight(voteId, total, userId); aiInsight = AiInsightView.unavailable(); @@ -86,6 +87,15 @@ public ShareLinkResult getShareLink(Long voteId) { ); } + private AiInsightView generatePersonalizedAiInsight(Long voteId, Long userId, Long selectedOptionId) { + Optional result = personalizedAiInsightService + .getOrGenerate(voteId, userId, selectedOptionId); + + return result + .map(r -> AiInsightView.of(r.headline(), r.body())) + .orElse(AiInsightView.unavailable()); + } + private Insight buildMySelectionInsight(Long voteId, Long optionId, Long userId) { int selectionCount = (int) voteParticipationRepository.countByVoteIdAndOptionId(voteId, optionId); diff --git a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java index 40ff6d2b..0f31b612 100644 --- a/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/ImmersiveVoteQueryUseCase.java @@ -12,6 +12,11 @@ public interface ImmersiveVoteQueryUseCase { ImmersiveLiveResult getLive(Long voteId); + /** + * 랜덤 다음 투표 조회 (excludeIds 제외, 무한 순환) + */ + ImmersiveNextResult getNextRandom(List excludeIds, int size, Long userId, String anonymousId); + record ImmersiveFeedResult(List items, Long nextCursor, boolean hasNext) { } @@ -44,4 +49,10 @@ record ImmersiveLiveResult( record LiveOptionItem(Long optionId, long voteCount, int ratio) { } + + /** + * 랜덤 다음 투표 조회 결과 (무한 순환용) + */ + record ImmersiveNextResult(List items) { + } } diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java index f02a33eb..20541d78 100644 --- a/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/VoteCommandUseCase.java @@ -3,6 +3,7 @@ import com.ject.vs.vote.domain.Vote; import com.ject.vs.vote.domain.VoteDuration; import com.ject.vs.vote.domain.VoteStatus; +import org.springframework.web.multipart.MultipartFile; import java.time.Clock; import java.time.Instant; @@ -12,6 +13,8 @@ public interface VoteCommandUseCase { VoteCreateResult create(VoteCreateCommand command); + VoteCreateResult createWithImages(VoteCreateWithImagesCommand command); + ParticipateResult participateAsMember(Long voteId, Long userId, Long optionId); ParticipateResult participateAsGuest(Long voteId, String anonymousId, Long optionId); @@ -29,6 +32,17 @@ record VoteCreateCommand( ) { } + record VoteCreateWithImagesCommand( + String title, + String content, + MultipartFile thumbnailFile, + MultipartFile imageFile, + VoteDuration duration, + String optionA, + String optionB + ) { + } + record VoteCreateResult(Long voteId, VoteStatus status, Instant endAt) { public static VoteCreateResult from(Vote vote, Clock clock) { return new VoteCreateResult(vote.getId(), vote.getStatus(clock), vote.getEndAt()); diff --git a/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java b/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java index 172a3adc..24133829 100644 --- a/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java +++ b/src/main/java/com/ject/vs/vote/port/in/VoteQueryUseCase.java @@ -22,6 +22,9 @@ public interface VoteQueryUseCase { VoteOption getSelectedOption(Long voteId, Long userId); + /** 투표 취소 등 참여 이력이 없으면 empty — 채팅 senderVoteOption null 처리용 */ + Optional findSelectedOptionCode(Long voteId, Long userId); + int getParticipantCount(Long voteId); /** 채팅 도메인 getChatList() 호환용 — 실제 Vote.endAt 기준으로 필터링 */ diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 2b8061b4..9b911b6b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -56,6 +56,9 @@ app: allow-credentials: ${APP_CORS_ALLOW_CREDENTIALS:true} max-age: ${APP_CORS_MAX_AGE:3600} +springdoc: + server-url: https://api.vs.io.kr + management: server: port: 8081 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4cc3e61a..7f6cc4a1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,11 +17,14 @@ aws: gemini: project-id: ${GEMINI_PROJECT_ID:} - location: ${GEMINI_LOCATION:asia-northeast3} - model: ${GEMINI_MODEL:gemini-1.5-flash} + # 서울(asia-northeast3)은 Gemini 모델 미지원 → us-central1 사용. GEMINI_LOCATION으로 override 가능 + location: ${GEMINI_LOCATION:us-central1} + # gemini-1.5 retired, 2.0-flash는 이 프로젝트 접근권 없음(404). 접근 확인된 2.5-flash 사용. GEMINI_MODEL로 override 가능 + model: ${GEMINI_MODEL:gemini-2.5-flash} enabled: ${GEMINI_ENABLED:false} # 오늘의 추천 투표 설정 권한 (운영진 user id 목록) admin: user-ids: - 7 + - 260 diff --git a/src/main/resources/data/slang.txt b/src/main/resources/data/slang.txt new file mode 100644 index 00000000..4b44de38 --- /dev/null +++ b/src/main/resources/data/slang.txt @@ -0,0 +1,2141 @@ +가슴빨아 +가슴빨어 +가슴조물락 +가슴주물럭 +가슴쪼물딱 +가슴쪼물락 +가슴핧아 +가슴핧어 +갈보 +강간 +개걸레 +개놈 +개년 +개등신 +개병신 +개보쥐 +개보지 +개부랄 +개시키 +개새끼 +개십 +개십새 +개십세 +개쓰레기 +개씨발 +개씨펄 +개자식 +개자지 +개잡년 +개잡지랄 +개젓가튼넘 +개좃 +개좆 +개죷 +개지랄 +걔잡년 +걔잡지랄 +굿보지 +근친상간 +깔다구 +깔따구 +깝친다 +꼴리다 +난교 +남창 +내꺼빨아 +내꺼핧아 +내미랄 +내버지 +내씨발 +내자지 +내잠지 +내조지 +너거애비 +너거어마이 +너그어미 +뇬 +뉘귀미 +뉘긔미 +뉘기뮈 +뉘기미 +뉘김 +뉘뮈 +뉘미 +뉘미랄 +뉘미럴 +뉘미룰 +뉘이미 +뉭기리 +뉭기미 +느그애미 +느그애비 +느그엄마 +느그에미 +느그에비 +늬귀미 +늬긔믜 +늬긔미 +늬기뮈 +늬기미 +늬믜 +늬미 +닁기미 +닁김 +니귀뮈 +니귀미 +니그으미 +니긔미 +니기뮈 +니기믜 +니기미 +니김이 +니매랄 +니매럴 +니머럴 +니뭬랄 +니뭬럴 +니뮈 +니밀할 +니아범 +니애미 +니애비 +니어미 +니엄마 +니에미 +니에비 +니이미 +니조개 +니조게 +니좃 +니할애비 +님이럴 +닝기리 +닝기미 +다무러라 +닥대가리 +닥대갈 +닥대구빡 +닥처 +닥쳐 +닥초 +닥쵸 +닥춰 +닥치 +닦처 +닦쳐 +닦초 +닦쵸 +닦춰 +닦치 +닭대가리 +닭대갈 +닭데갈 +닭뒈가리 +닭쳐 +닳은년 +대가뤼 +대가리 +대갈 +대져 +대주까 +대줄께 +대줄년 +대질래 +대질레 +댁빠리 +덜은새끼 +덩쉰 +덩신 +데가뤼 +데가리 +데갈텅 +데갈통 +데저라 +데져 +데져라 +데질레 +도라이 +돈년 +돈새끼 +돌겡이 +돌대가리 +돌림빵 +돌마니 +돌머리 +돌아이 +돌았나 +돌은년 +돌은넘 +돌은새끼 +돼가리 +되질래 +둇같은 +둔녀 +뒈가리 +뒈져 +뒈져라 +뒈진다 +뒈질 +뒈질래 +뒈질레 +뒤싸질놈 +뒤여질놈 +뒤저라 +뒤져 +뒤진 +뒤질 +뒤짐 +드러운놈 +드러운뇬 +드러운새끼 +등쉰 +등시 +등신 +등신새끼 +등신세끼 +듸랄 +듸져 +디랄 +디람 +디럴 +디저라 +디져 +디져라 +디진다 +디질껴 +디질래 +디질레 +따먹기 +따먹어 +따무라 +따묵기 +딸녀 +딸따뤼 +딸따리 +딸딸 +딸순이 +딸치냐 +떠라이 +떡걸 +떡촌 +떡치기 +떨거지 +떨겡이 +떨마니 +떨아이 +또라이 +똘아이 +띠부랄 +띠불 +룸섹스 +막간년 +막대쑤셔줘 +막대핧아줘 +맛간년 +맛없는년 +맛이간년 +맞아죽을놈 +매춘 +매친뇬 +머저리 +멍청한년 +메친 +메틴넘 +몸파는 +몸파라 +미씨촌 +미이친뇬 +미친놈 +미친년 +미친뇬 +바근가시네 +밥튕 +밥팅 +백보지 +뱅신 +버러지 +버짓물 +벙섹 +벵신 +벼엉신 +변녀 +변섹 +변태 +병닥 +병딱 +병쉰 +병싀 +병싄아 +병시 +병신 +보뒤 +보오지 +보쥐 +보쥐털 +봉알 +뵹신 +부랄 +부럴 +부롤 +불알 +붕신 +붕알 +뷔웅신 +뷩세 +뷩쉰 +뷩시 +뷩신 +븅 +븅따 +븅딱 +븅새끼 +븅쉰 +븅시나 +븅신 +브라알 +브라운더스트 +브라자 +브랄 +브럴 +븡딱 +븡신 +븡알 +븨융신 +빙쉰 +빙신 +빙신아 +빙아 +빠구뤼 +빠구리 +빠꾸뤼 +빠꾸리 +빠돌이 +빠순이 +빠큐 +빡순이 +빡촌 +빡큐 +빡통 +빨자좃 +빽보지 +뻐뀨 +뻐르노 +뻐큐 +뻐킹 +뻑뀨 +뻑유 +뻑큐 +뽀로노 +뽀르나 +뽀르너 +뽀르노 +뽀쥐털 +뽀즤 +뽀지 +뽀큐 +뽕알 +뿡알 +쁑신 +삐꾸 +삥신 +사까쉬 +사까시 +사까치 +사창가 +사카시 +사카씨 +사타구니 +삿깟시 +상년 +상놈 +상늠 +새꺄 +새끼 +새대가리 +새대갈 +새대구빡 +새데가리 +새데갈 +새데구빡 +새킈 +새키 +색걸 +색골 +색귀 +색긔 +색기 +색뀌 +색끼 +색녀 +색마 +색스 +색쑤 +색쓰 +생리 +샹년 +샹노마 +샹놈의새리 +샹놈의쌔리 +샹뇬 +샹늠의새리 +샹늠의쌔리 +샹팔개년 +선오브비치 +성감대 +성경험 +성관계 +성교 +성상담 +성생활 +성섹스 +성욕구 +성체위 +성폭력 +성폭행 +세끼 +섹걸 +섹골 +섹귀 +섹기 +섹끼 +섹남 +섹녀 +섹마 +섹보지 +섹소리 +섹수 +섹슈 +섹스 +섹쑤 +섹쓰 +섹티쉬 +섹파트너 +섹하자 +섹하장 +쇄대가리 +쇠대가리 +쇠대갈 +쇠대구빡 +쇠데가리 +쇠데갈 +쇠데구빡 +수간 +수음 +쉐꺄 +쉐끼 +쉐팔 +쉑 +쉑끼 +쉑스 +쉬댕 +쉬뎅 +쉬바 +쉬박 +쉬발 +쉬방 +쉬배 +쉬밸 +쉬벅 +쉬벌 +쉬벙 +쉬벨 +쉬봉 +쉬불 +쉬붕 +쉬브랄 +쉬브럴 +쉬블 +쉬빨 +쉬뻘 +쉬뿔 +쉬이발 +쉬이방 +쉬이벌 +쉬이불 +쉬이붕 +쉬이빨 +쉬이팔 +쉬이펄 +쉬이풀 +쉬파 +쉬팍 +쉬팍아 +쉬팔 +쉬팡 +쉬퍼얼 +쉬펄 +쉽새 +쉽색 +쉽세 +쉽섹 +쉽쉐 +쉽쌕 +쉽쎄 +쉽쎅 +쉽알 +쉽쟁이 +쉽질 +쉽창 +쉽탱구리 +쉽파 +쉽팍 +쉽팔 +쉽펄 +쉽할 +쉽헐 +쉽활 +쉿발 +쉿벌 +쉿빨 +쉿뻘 +쉿팍 +쉿팔 +쉿펄 +스너프 +스발 +스벌 +스와핑 +스팔 +스펄 +싀댈 +싀델 +싀뎅 +싀바라 +싀발 +싀밸 +싀벌 +싀벨 +싀봉 +싀봘 +싀팍 +싀팔 +싀팤 +싀펄 +싀퐐 +싑새 +시댕 +시뎅 +시바 +시발 +시밸 +시뱅 +시뱅년 +시뱔 +시버럴 +시벌 +시벨 +시벵 +시볼 +시봉 +시부랄 +시부럴 +시불 +시붕 +시브랄 +시블 +시빨 +시뺠 +시뿔 +시이발 +시이벌 +시이불 +시이붕 +시이팔 +시이펄 +시이풀 +시입팔 +시탈놈 +시파 +시팍 +시팔 +시팡 +시팰 +시펄 +시풀 +식펄년이 +신음소리 +실밸 +심년아 +십녀 +십년 +십때끼 +십땡구리 +십땡그리 +십땡글이 +십떼끼 +십물 +십발 +십버지 +십벌 +십볼 +십부랄 +십부럴 +십불 +십블 +십빡 +십빨 +십뻘 +십뽈 +십뿔 +십쁠 +십새 +십새리 +십색 +십세 +십셰리 +십쉐 +십쌔 +십쌔끼 +십쌕 +십쎄 +십쎅 +십알 +십을년 +십자슥 +십지랄 +십질 +십창 +십탱 +십텡 +십파 +십팍 +십팔 +십팡넘아 +십펄 +십할 +십헐 +싯발 +싯벌 +싯볼 +싯빨 +싯뻘 +싯팔 +싶새 +싶세 +싶쉐 +싶쌔 +싶쎄 +싶알 +싶질 +싶창 +싶팍 +싶팔 +싶펄 +싶할 +싶헐 +싶활 +싸까쉬 +싸까시 +싸까씨 +싸까지 +싸까찌 +싸넘 +싸년 +싸놈 +싸뇬 +싸늠 +싸발 +싸벌 +싸이코 +싸카시 +싸카씨 +싹바가지 +싹박아지 +쌉쌕뀌 +쌍넌 +쌍넘 +쌍년 +쌍놈 +쌍뇬 +쌍늠 +쌍보지 +쌍통 +쌔기 +쌔꺄 +쌔끼 +쌔키 +쌕 +쌕걸 +쌕기 +쌕년 +쌕수 +쌕쉬 +쌕스 +쌕쑤 +쌕쓰 +쌩쇼 +쌩쑈 +쌩아 +쌰다마우쓰 +쌰럽 +쌰앙 +썅 +써글 +써발넘 +썩을 +쎄기 +쎄꺄 +쎄끈 +쎄끼 +쎄엑 +쎄엑스 +쎅 +쎅기 +쎅수 +쎅스 +쎅이 +쎅히 +쐉년 +쐉뇬 +쐐끼 +쐐대가리 +쐐대갈 +쐐대구빡 +쐐데가리 +쐐데갈 +쑈우망가 +쑤댕 +쑤발 +쑤벌 +쑤셔 +쑤팔 +쑤팡 +쑤펄 +쑵 +쒜 +쒜끼 +쒜뒈가리 +쒝 +쒝스 +쒞 +쒭 +쒯 +쒲 +쒸 +쒸댕 +쒸바 +쒸박 +쒸발 +쒸방 +쒸배 +쒸밸넘아 +쒸버 +쒸벌 +쒸벨 +쒸봘 +쒸부랄 +쒸부럴 +쒸불 +쒸붕 +쒸뷰랄 +쒸뷰럴 +쒸블 +쒸빨 +쒸알 +쒸앙 +쒸양 +쒸이발 +쒸이방 +쒸이벌 +쒸이불 +쒸이붕 +쒸이빨 +쒸이팔 +쒸이펄 +쒸이풀 +쒸팍 +쒸팔 +쒸팡색기 +쒸팡색끼 +쒸펄 +쒸퐉 +쒸퐐넘 +쒸퐐놈 +쒸풀 +쓉 +쓋 +쓰글 +쓰댕 +쓰뎅 +쓰렉 +쓰바 +쓰바새끼 +쓰바할 +쓰발 +쓰방쉐 +쓰밸 +쓰버럴 +쓰벌 +쓰벨 +쓰벵 +쓰봉 +쓰봉쉐 +쓰불 +쓰붕 +쓰붕쉐 +쓰뷀 +쓰브랄 +쓰브랄쉽세 +쓰블 +쓰븡 +쓰빌 +쓰빨 +쓰뻘 +쓰알 +쓰으발 +쓰으벌 +쓰으블 +쓰으팔 +쓰으펄 +쓰파 +쓰팔 +쓰팡 +쓰펄 +쓰퐈 +쓱을 +씁벌 +씁새 +씁색 +씁세 +씁쉐 +씁쌕 +씁쎅 +씁쒜 +씁아 +씁얼 +씁탱 +씁헐 +씌댕 +씌바 +씌박 +씌발 +씌방 +씌배 +씌밸 +씌벌 +씌벙 +씌벨 +씌불 +씌블 +씌빌 +씌빨 +씌뻘 +씌앙 +씌양 +씌파 +씌팍 +씌팔 +씌팡 +씌펄 +씌퐈 +씌퐐 +씌퐝 +씝 +씨가랭넘 +씨가랭년 +씨가랭놈 +씨끼 +씨다바리 +씨달 +씨댕 +씨댕년 +씨댕이 +씨뎅 +씨바 +씨바랄 +씨바새끼 +씨바알 +씨박 +씨발 +씨밟 +씨방새 +씨방세 +씨방쉐 +씨배 +씨밸 +씨뱅 +씨뱅가리 +씨뱅이 +씨뱔 +씨버럴 +씨벌 +씨벨 +씨벨세끼 +씨벵 +씨벵이 +씨보지 +씨볼 +씨봉 +씨봘 +씨부댕 +씨부랄 +씨부럴 +씨부렁 +씨부롤 +씨부리 +씨불 +씨붕 +씨브랄 +씨브럴 +씨브룰 +씨블 +씨븡새끼 +씨비랄 +씨빠 +씨빡 +씨빨 +씨뺄뇬아 +씨뺠 +씨뽀랄 +씨뽈 +씨뿔 +씨쁠 +씨섹끼 +씨앙 +씨이발 +씨이방 +씨이뱅 +씨이벌 +씨이불 +씨이붕 +씨이블 +씨이빨 +씨이팔 +씨이펄 +씨이풀 +씨입쉐 +씨입팔 +씨입할 +씨탱 +씨텡 +씨파 +씨팍 +씨팔 +씨팝 +씨팡 +씨펄 +씨폴 +씨퐁 +씨퐈 +씨퐝 +씨풀 +씨플 +씨할 +씨활 +씹 +씹년 +씹념이 +씹닥 +씹딱 +씹땡구리 +씹땡그리 +씹만한 +씹물 +씹미랄 +씹발 +씹버지 +씹벌 +씹보지 +씹볼뇬 +씹부랄 +씹브랄 +씹빨 +씹빵구 +씹뻘 +씹뽀지 +씹뿔 +씹쁠 +씹새 +씹색 +씹세 +씹세리 +씹섹 +씹숑 +씹쉐 +씹쌔 +씹쌕 +씹아 +씹알 +씹어 +씹얼 +씹울알 +씹을년 +씹자석 +씹자슥 +씹자식 +씹자지 +씹장이 +씹쟁이 +씹쥘 +씹지랄 +씹질 +씹창 +씹탱 +씹텡 +씹팔 +씹퍼년 +씹펄년아 +씹할 +씹헐 +씹활 +씻발 +씻밸 +씻벨 +씻볼 +씻불 +씻빨 +씻뻘 +씻뽈 +씻뿔 +씻탱구리 +씻팔 +씻팡 +씻펄 +씻펑 +앂 +앂빨 +앂팔 +아가리 +아갈 +아괄 +아리랑치기 +아씨발 +애미 +애비 +애뮈 +애믜 +애뷔 +애액 +애좌 +앰병 +앰빙 +앰창 +앵신새끼 +야게임 +야겜 +야께임 +야껨 +야오이 +양년 +양아취 +양아치 +얨병 +얼간이 +엄마먹었당 +엄창 +엠병 +엠창 +여엄병 +연놈 +연병 +엳빠 +염뱅할 +염병 +염뵹 +엿 +엿가튼 +엿같은 +엿같이 +엿놈 +엿머거 +엿먹어 +엿먹으셈 +엿이다 +엿쳐무라 +옂빠 +옅빠 +옘병 +옘빙 +오나니 +오랄 +오럴 +오르가즘 +오입 +올가즘 +옴창 +왕버지 +왕자지 +왕잠지 +왕털버지 +왕털보지 +왕털자지 +왕털잠지 +요년 +욧 +우라질 +원조교제 +유깝 +유깦 +유방핧아 +유방핧어 +유욱갑 +육갑 +육갚 +육구자세 +육깝 +육깦 +육봉 +육시럴 +육실할 +육실헐 +육씨랄년 +육씨랄놈 +윤간 +음욕 +이년 +이새꺄 +이세꺄 +이쇅 +이쉐꺄 +이쉑 +이쉑꺄 +이시끼 +이자식 +입걸레 +자기핧아 +자쉭 +자쥐 +자쥐털 +자즤 +작은보지 +잠지 +잠짓물마셔 +잡것 +잡껏 +잡넘 +잡년 +잡노무 +잡놈 +잡뇬 +잡종 +잡탱아 +재섭서 +재섭써 +재섭어 +재셥네 +저까 +저깐 +저년 +저떠 +저떠씨벵 +저빱 +저세끼 +저썌끼 +저엇 +저엊 +적까 +전까 +전만한 +점물 +젓가 +젓같 +젓까 +젓꺽쥐 +젓꺽지 +젓꼭쥐 +젓꼭지 +젓나 +젓냄새 +젓대가리 +젓떠 +젓또 +젓랄 +젓마나 +젓마니 +젓마무리 +젓만아 +젓만이 +젓만한게 +젓물 +젓밥 +젓빱 +젓빺 +젓어었더 +젓잡고 +젓카 +정신병자 +젖 +젖가 +젖같 +젖까 +젖꺽쥐 +젖꺽지 +젖꼭쥐 +젖꼭지 +젖대가리 +젖더 +젖도 +젖떠 +젖또 +젖마나 +젖만한 +젖밥 +젖빱 +젖빺 +젖잡고 +젖탱이 +젗 +젗잡고 +제수없다 +져까 +져더 +져떠 +져빱 +졎 +졏 +조가튼 +조건만남 +조까 +조깐냐 +조깨 +조께 +조빱 +조빺 +조쌔리뿔라 +조온나 +조옷 +조옺 +조쟁이 +조져버린다 +조진다 +조질래 +조팝 +족가튼넘 +족가튼년 +족갗 +족같내 +족같네 +족까 +존나 +존니 +존라 +존마 +존마난 +존마니 +존만새끼 +존만아 +존만한 +존만히 +존먹어 +졷 +졸같은넘 +졸까라 +좀마난 +좀마니 +좀물 +좀쓰레기 +좁 +좁년 +좁물 +좁밥 +좁빠라라 +좃가튼 +좃간년 +좃같네 +좃같은 +좃까 +좃깟네 +좃깨 +좃께 +좃나 +좃냄새 +좃넘 +좃니재수야 +좃대가리 +좃대로 +좃도 +좃떠 +좃또 +좃마무리 +좃만아 +좃만이 +좃만한 +좃먹어 +좃물 +좃밥 +좃보지 +좃부랄 +좃빠구리 +좃빠네 +좃빠라 +좃빱 +좃빺 +좃잡고 +좃털 +종간나 +좆 +좇 +좈 +좉 +좊 +좋만한것 +좌쉭 +좌슥 +좝것 +좨랄 +좨수없다 +죄수없다 +죠또 +죠랄하네 +죡 +죤 +죤나 +죳까 +죴 +죵 +죵나 +죷 +주글년 +주글래 +주길년 +주둥아리 +죽고싶냐 +죽구십냐 +죽구싶냐 +죽을년 +죽을놈 +죽을래 +죽이삔다 +죽일년 +죽일놈 +줴수없다 +쥐랄 +쥐럴 +쥐뢀 +쥐룰 +쥐새키 +쥐세키 +쥐이럴 +쥗 +즤랄 +지랄 +지럴 +지롤 +지뢀 +지룰 +지어미 +지에미 +지이랄 +지이럴 +지져버려 +지핥 +질알 +질얼 +질올 +질할 +짜쥐 +짜쥐털 +짜즤 +짜지 +짜지털 +짬지 +짭새 +짭쉐 +쨔저 +쨔져 +쪼가리 +쪼갈 +쪼개지마 +쪼까 +쪼다 +쪽빠리 +쫍밥 +쬬개 +쬬까 +쭀 +쭈둥아리 +쮜랄 +찌랄 +찐따 +찢어죽일년 +찢어죽일놈 +차앙녀 +창녀 +창년 +창놈 +창뇨 +창뇬 +처먹어 +처박다 +처죽일년 +처죽일놈 +첫더 +쳐먹어 +쳐쑤셔박어 +쳐죽인다 +쳐죽일것 +쳐죽일년 +쳐죽일놈 +쳤같다 +촌나시펄 +촌씨브라리 +촌씨브랑이 +촌씨브랭이 +촹뇬 +춎마라라 +춎이다 +츠발 +친넘 +캐새끼 +캐쉐리 +캐쉑끼 +컴색 +컴세엑 +컴섹 +컴쌕 +컴쎅 +콘돔 +크리토리스 +큰보지 +클리토리스 +튀껍냐 +튀꼽 +튀꼽냐 +티껍 +티껍냐 +티꼽 +티꼽냐 +티발 +티이발 +파큐 +패니스 +패티쉬 +패티시 +팰라치오 +퍼르노 +퍼큐 +퍽유 +퍽큐 +펀섹 +펔규 +펔뀨 +펔유 +펔큐 +페니스 +페티걸 +페티쉬 +페티시 +펠라치오 +폐니스 +폐티걸 +폐티쉬 +폐티시 +포뀨 +포로너 +포로느 +포르나 +포르너 +포르노 +포르로 +포온섹 +포큐 +폭뀨 +폰색 +폰섹 +폰쎅 +풀알 +할타 +핥 +핥아 +함주까 +핸타이 +허뎝 +허버리년 +헐렁보지 +헨타이 +헴타이 +호구 +호구쉐끼 +호냥년 +호러세귀 +호빠 +호스트빠 +호스티스 +호양년 +호오로 +호테스바 +호테스빠 +혼음 +화냥년 +화냥뇬 +후라덜넘 +후라들년 +후래자식 +후뢰자식 +후리지마 +후릴년 +후좡 +뵨태 +윤락 +가슴만져 +거시기 +년 +놈 +발기 +버지 +보랄 +보럴 +뽀쥐 +상급워록 +색남 +색수 +음부 +거기나빨아랑 +씨방 +씨방아 +씹다 +아씨팔젖같네 +애널 +애무 +어미 +에미 +에비 +음경 +쥐엠 +지엠 +창남 +창여 +혀로보지핧기 +혀로보지핱기 +후다 +후장 +가운대다리 +개가튼 +개갈보 +개같은 +개넘 +개논 +개뇬 +개뇽 +개눔 +개늠 +개늠아 +개뵹신 +개불랄 +개불알 +개붕알 +개새 +개색 +개생퀴 +개샠 +개샤끼 +개세 +개세이 +개섹 +개쇄 +개쇅 +개쉐 +개쉑 +개쉬바 +개쉬발 +개쉬벌 +개시끼 +개시바 +개시발 +개쌍넘 +개쌍년 +개쌍놈 +개쌍뇬 +개쌔 +개쌕기 +개썌끼 +개쎄끼야 +개쒜리 +개쓉 +개쓰래기 +개씁 +개씨바 +개씨블 +개씨뻘 +개씹 +개씹세 +개자석 +개자쉭 +개자슥 +개자씩 +개자쥐 +개젓 +개젗 +개졋 +개졎 +개조옷 +개족 +개좇 +개호러 +걸래가튼뇬 +걸래같은년 +걸래년 +게가튼 +게같은 +게넘 +게년 +게놈 +게뇬 +게눔 +게늠 +게늠아 +게보쥐 +게보지 +게부럴 +게새기 +게새끼 +게색 +게세 +게섹 +게쉐리 +게쉐이 +게쉑 +게쉬키 +게시끼 +게쎄기 +게쎄끼 +게자쉭 +게자슥 +게자식 +게자쥐 +게자지 +게젓 +게좆 +게지뢀 +계색갸 +계세키 +궤색 +궤섹 +궤시끼 +궤자지 +깨쌔끼 +뉘미쉬벌 +늬음마 +니뮈쒸발 +니미밑구녕 +니미쉬벌 +니미쒸발 +니미쒸블 +니미좃 +니보지 +니보지구멍 +니뽕좃이다 +니어미배째 +니자지 +니조지다 +니좃대로 +니죠때 +닝기미씹 +닝기미좃 +대갈빡 +대갈통 +대저라 +데갈 +데구빡 +말버지 +말좆 +뮈칀세끼 +뮈칀세리 +뮈칀세이 +뮈칀섹끼 +뮈친세리 +뮈친세이 +뮈친섹끼 +뮈친쉑이 +미친 +미친새끼 +미췬 +미췬세끼 +미칀 +미친개새끼 +미친개색기 +미친개쉑 +미친쌍년 +미친싸앙년 +미친싸앙놈 +미친쌍농 +미친쌍뇽 +미친썅년 +미친썅놈 +미친씹쌔 +미친씹쌔끼 +미친씹쎄끼 +미친좃대가리 +미칭논아 +미튄 +미티넘 +미틴 +밑구녕 +밑구멍 +뱅신시키 +버쥐 +버즤 +버지물 +벼엉시나 +벼엉시이 +벼잉신 +병쉬 +병신새 +병신새끼 +병신색기 +병신쉐리 +병신쉐이 +병어신 +병엉신 +보즤 +보지 +보지구녕 +보지뇬 +보짓년 +보짓물 +보짖물 +보털 +봇털 +봉신 +뵝신 +부뢀 +부카케 +붕뛴 +붕싄 +뷩쉬 +뷩싄 +븅신새끼 +븅신색끼 +븅신색히 +븅신섹히 +블알 +빙신쉐리 +빙신쉐이 +빽자지 +뻐르나 +뻐르너 +뻨유 +뻨큐 +뽁큐 +뽈로노 +뿌랄 +뿌럴 +뿔알 +사앙녀 +사앙년 +삭바가지 +삭박아지 +상놈쉑 +상뇬 +상늠쉑 +샙때끼 +샤앙녀 +샤앙년 +샤앙놈 +샹논 +샹놈 +쉬부랄 +시벌존나 +시벌좃나 +시부랄넘 +시부랄녀언 +시부랄년 +시부랄놈 +시부랄뇬 +시부래미 +시불랑 +시불헐 +시앙년 +시양년 +십데꺄 +십아 +십우랄년아 +십자석 +십장이 +십쟁이 +싰발 +싰벌 +싶발 +싶벌 +싶빡 +싶빨 +싶뻘 +싸앙년 +싸앙뉸 +싹년 +쌍뇽 +쌍뉸 +쌍부랄 +쌰앙녀 +쌰앙년 +쌰앙뇬 +쌰앙뉸 +썅넌 +썅년 +썅악년 +썅악여 +썅악연 +썅앙년 +썅연 +쓉창 +쓰부랄 +씁창 +씝창 +씨발새끼 +씨발개새끼 +씨발잡놈 +씨발개잡놈 +씨배랄 +씨베랄 +씨베렐 +씨봉알 +씨부랠 +씨부럴잡놈 +씨부럴개잡놈 +씨부얼 +씨불알 +씨불얼 +씨불할 +씨불헐 +씨앙년 +씨양년 +씨창 +씹구멍 +씹까 +씹년양아치 +씹젖 +씹좆 +씹좇 +자지 +저까튼 +저꽈튼 +저꽛튼 +적가튼 +적꽈튼 +젓가튼 +젓갓 +젓같내 +젓같네 +젓같다 +젓같은 +젖가튼 +젖같네 +젖같은 +젗같 +져꽈튼 +졋같다 +졎같다 +조가틍 +조까치 +조까튼 +조깓 +조깟 +조깠 +조깥 +조끗 +조끗네 +조또 +조빠 +조옷가튼 +조옷같 +조카툰 +족가튼 +족간네 +족같 +족같은 +족까치 +좃갔다 +좃같 +좃같내 +좃같다 +좃같아 +좃같어 +좃봉지 +좆가튼 +좆같 +좆같네 +좆같아 +좇가튼 +좇같 +좇같네 +좇같은 +죠까틍 +죠카태 +죠카테 +죠카툰 +죠콰떼 +죠콰태 +죠콰테 +죠콰퉤 +죧같 +죧만한 +죶가튼 +죶같은 +쪼가튼 +쫃갇네 +쫃같네 +쫓같 +캐병신 +캐븅신 +호구새끼 +호러새끼 +흐접쌔끼 +로리타 +로리 +리타 +아동포르노 +미성년자포르노 +아동매춘 +아동매춘물 +아동음란물 +아동물 +망가 +여고생교복야동 +여고생교복 +여고생야동 +교복야동 +로리콤 +로리콘 +개잡놈 +미친쓰레기 +보슬아치 +개쓰레기잡놈 +씨발색기 +씨발놈 +씨발놈의새끼 +샹늠 +샹놈의새끼 +미친씨발 +미친씨발놈 +간철수 +개쌍도 +경상디언 +개쌍디언 +김치년 +근혜찡 +규재찡 +노운지 +운지 +로린이 +멍청도 +빵칼 +빵즈 +박원숭 +씹선비 +설라디언 +스시녀 +원조가카 +노오란그분 +네다홍 +좌이트 +좌음 +좌빨 +전라디언 +탈라도 +빨갱이 +종북 +좌좀 +낙태충 +수꼴 +일베 +일베충 +노무현 +박근혜 +이명박 +박정희 +김대중 +김영삼 +이승만 +노태우 +전두환 +피망 +피망플러스 +스마트피망 +포털앱 +플러스피망 +나성균 +나성균회장 +이기원 +이기원대표 +이기원대표님 +최관호 +최관호대표 +최관호대표님 +차승희 +차승희대표 +차승희대표님 +오용환 +오용환대표 +오용환대표님 +현재진 +윤준식 +김승철 +김정랑 +네오위즈인터넷대표 +네오위즈인터넷사장 +네오위즈인터넷 +네오위즈 +네오위즈게임즈 +네오위즈모바일 +네오위즈씨알에스 +네오위즈상담원 +네오위즈상담 +네오위즈고객센터 +네오위즈고객지원 +네오위즈서비스센터 +벅스 +세이클럽 +운영자 +운영진 +운영팀 +관리자 +개발자 +개발팀 +게시판관리자 +피엠 +피디 +소비자보호원 +신고접수센터 +싸이버수사대 +싸이버수사대원 +싸이버신고센터 +싸이버접수센터 +고객센터 +게임물등급위원회 +공정거래위원회 +사이버수사대 +사이버수사대원 +사이버신고센터 +사이버접수센터 +인터넷진흥원 +넥스트앱스 +데브클랜 +피코네코 +탭소닉 +워크라이시스 +삼국대전 +하이피킹덤 +탭소닉스타 +모리노리 +마법전쟁 +알투비트 +밀땅무림 +타이니월드 +탭소닉링스타 +하이피드래곤 +디제이맥스래이 +베이스볼쇼다운 +케리레이싱 +피망포커 +피망맞고 +빅토리워즈 +워스토리 +파랜드워 +가속스캔들 +스페셜포스 +드래곤플라이 +박철우 +에이피스튜디오 +박인찬 +히어로즈 +나이트워커 +마정민 +청풍명월 +저스트나인 +김진상 +캣츠앤독스 +마왕전 +유엑스플러스 +박범진 +로켓찹 +모라코 +황민중 +아크베어즈 +유성원 +소울하츠 +스페셜포스퍼스트미션 +퍼스트미션 +마스터리그 +마성의플러스 +킹덤오브히어로 +싸워싸워아레나 +싸워싸워 +아레나 +두근두근빙고 +소리바다 +핑거나이츠 +봉신연의 +도데카 +그라나사 +와일드기어 +룬의기사 +진봉신연의 +퓨리아이 +이엑스피백 +채윤호 +김정심 +올림픽 +리오올림픽 +알빠노 +누칼협 +어쩔티비 +개선주 +느개비 +한남충 +메갈년 +재기해 +이기야 +데스웅 +쎾쓰 +빡머갈 +빡대가리 +졸라 +자위 +딸딸이 +야동 +야사 +근친 +쇼타 +조두순 +박사방 +버닝썬 +갓갓 +박사 +메갈 +워마드 +한남 +소추 +소추소심 +틀딱 +틀니 +틀니압수 +틀니딱딱 +급식충 +잼민이 +초딩 +중딩 +고딩 +보딩 +자딩 +씹창놈 +씹창년 +보지물 +자지물 +정액 +쿠퍼액 +질내사정 +질외사정 +질싸 +입싸 +얼싸 +후싸 +항문 +똥꼬 +똥꼬충 +항문섹스 +펨돔 +멜돔 +스팽 +관장 +피스팅 +스캇 +소아성애 +시간 +물뽕 +졸피뎀 +프로포폴 +바카라 +토토 +꽁머니 +꽁포 diff --git a/src/unitTest/java/com/ject/vs/VsServerApplicationTests.java b/src/unitTest/java/com/ject/vs/VsServerApplicationTests.java index 67823fc2..048a3669 100644 --- a/src/unitTest/java/com/ject/vs/VsServerApplicationTests.java +++ b/src/unitTest/java/com/ject/vs/VsServerApplicationTests.java @@ -1,14 +1,18 @@ package com.ject.vs; -import com.ject.vs.notification.port.out.PushSenderPort; +import com.ject.vs.image.port.ImageService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @ActiveProfiles("test") @SpringBootTest class VsServerApplicationTests { + @MockitoBean + private ImageService imageService; + @Test void contextLoads() { } diff --git a/src/unitTest/java/com/ject/vs/ai/port/PersonalizedAiInsightServiceTest.java b/src/unitTest/java/com/ject/vs/ai/port/PersonalizedAiInsightServiceTest.java new file mode 100644 index 00000000..61c4714f --- /dev/null +++ b/src/unitTest/java/com/ject/vs/ai/port/PersonalizedAiInsightServiceTest.java @@ -0,0 +1,155 @@ +package com.ject.vs.ai.port; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.ject.vs.ai.port.in.AiInsightUseCase; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; +import com.ject.vs.ai.port.in.AiInsightUseCase.PersonalizedVoteInsightRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PersonalizedAiInsightServiceTest { + + @Mock + PersonalizedInsightDataCollector dataCollector; + + @Mock + AiInsightUseCase aiInsightUseCase; + + Cache cache; + + PersonalizedAiInsightService service; + + @BeforeEach + void setUp() { + cache = Caffeine.newBuilder().maximumSize(100).build(); + service = new PersonalizedAiInsightService(dataCollector, aiInsightUseCase, cache); + } + + PersonalizedVoteInsightRequest createRequest(String gender, String ageGroup) { + return new PersonalizedVoteInsightRequest( + "짜장면 vs 짬뽕", + "짜장면", 60, 60, + "짬뽕", 40, 40, + 100, 50, 50, "20s", + "짜장면", gender, ageGroup, + 65, 70, "짜장면", "짜장면" + ); + } + + @Nested + @DisplayName("getOrGenerate") + class GetOrGenerate { + + @Test + @DisplayName("캐시 미스 시 AI 호출하고 결과 캐싱") + void cachesMissCallsAiAndCachesResult() { + // given + Long voteId = 1L, userId = 100L, optionId = 10L; + PersonalizedVoteInsightRequest request = createRequest("MALE", "20s"); + AiInsightResult aiResult = new AiInsightResult("당신은 다수파!", "남성 20대의 65%가 동일 선택"); + + given(dataCollector.collect(voteId, userId, optionId)).willReturn(request); + given(aiInsightUseCase.generatePersonalizedInsight(request)).willReturn(Optional.of(aiResult)); + + // when + Optional result = service.getOrGenerate(voteId, userId, optionId); + + // then + assertThat(result).isPresent(); + assertThat(result.get().headline()).isEqualTo("당신은 다수파!"); + assertThat(result.get().body()).isEqualTo("남성 20대의 65%가 동일 선택"); + + // 캐시에 저장됨 + String cacheKey = request.cacheKey(voteId, userId, optionId); + assertThat(cache.getIfPresent(cacheKey)).isNotNull(); + } + + @Test + @DisplayName("캐시 히트 시 AI 호출 안함") + void cacheHitDoesNotCallAi() { + // given + Long voteId = 1L, userId = 100L, optionId = 10L; + PersonalizedVoteInsightRequest request = createRequest("FEMALE", "30s"); + AiInsightResult cachedResult = new AiInsightResult("캐시된 헤드라인", "캐시된 바디"); + + String cacheKey = request.cacheKey(voteId, userId, optionId); + cache.put(cacheKey, cachedResult); + + given(dataCollector.collect(voteId, userId, optionId)).willReturn(request); + + // when + Optional result = service.getOrGenerate(voteId, userId, optionId); + + // then + assertThat(result).isPresent(); + assertThat(result.get().headline()).isEqualTo("캐시된 헤드라인"); + + // AI 호출 안함 + verify(aiInsightUseCase, never()).generatePersonalizedInsight(any()); + } + + @Test + @DisplayName("AI 호출 실패 시 빈 결과 반환, 캐시 안함") + void aiFailureReturnsEmptyAndDoesNotCache() { + // given + Long voteId = 1L, userId = 100L, optionId = 10L; + PersonalizedVoteInsightRequest request = createRequest("MALE", "40s"); + + given(dataCollector.collect(voteId, userId, optionId)).willReturn(request); + given(aiInsightUseCase.generatePersonalizedInsight(request)).willReturn(Optional.empty()); + + // when + Optional result = service.getOrGenerate(voteId, userId, optionId); + + // then + assertThat(result).isEmpty(); + + // 캐시에 저장 안됨 + String cacheKey = request.cacheKey(voteId, userId, optionId); + assertThat(cache.getIfPresent(cacheKey)).isNull(); + } + + @Test + @DisplayName("다른 성별/연령대는 다른 캐시 키") + void differentDemographicsUseDifferentCacheKeys() { + // given + Long voteId = 1L, optionId = 10L; + + PersonalizedVoteInsightRequest maleRequest = createRequest("MALE", "20s"); + PersonalizedVoteInsightRequest femaleRequest = createRequest("FEMALE", "20s"); + + AiInsightResult maleResult = new AiInsightResult("남성 인사이트", "남성 바디"); + AiInsightResult femaleResult = new AiInsightResult("여성 인사이트", "여성 바디"); + + given(dataCollector.collect(voteId, 100L, optionId)).willReturn(maleRequest); + given(dataCollector.collect(voteId, 200L, optionId)).willReturn(femaleRequest); + given(aiInsightUseCase.generatePersonalizedInsight(maleRequest)).willReturn(Optional.of(maleResult)); + given(aiInsightUseCase.generatePersonalizedInsight(femaleRequest)).willReturn(Optional.of(femaleResult)); + + // when + Optional maleResultActual = service.getOrGenerate(voteId, 100L, optionId); + Optional femaleResultActual = service.getOrGenerate(voteId, 200L, optionId); + + // then + assertThat(maleResultActual.get().headline()).isEqualTo("남성 인사이트"); + assertThat(femaleResultActual.get().headline()).isEqualTo("여성 인사이트"); + + // 둘 다 AI 호출됨 (캐시 키가 다름) + verify(aiInsightUseCase, times(2)).generatePersonalizedInsight(any()); + } + } +} diff --git a/src/unitTest/java/com/ject/vs/ai/port/PersonalizedInsightDataCollectorTest.java b/src/unitTest/java/com/ject/vs/ai/port/PersonalizedInsightDataCollectorTest.java new file mode 100644 index 00000000..ff4f0fda --- /dev/null +++ b/src/unitTest/java/com/ject/vs/ai/port/PersonalizedInsightDataCollectorTest.java @@ -0,0 +1,232 @@ +package com.ject.vs.ai.port; + +import com.ject.vs.ai.port.in.AiInsightUseCase.PersonalizedVoteInsightRequest; +import com.ject.vs.user.domain.Gender; +import com.ject.vs.user.domain.User; +import com.ject.vs.user.domain.UserRepository; +import com.ject.vs.vote.domain.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.*; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; + +import java.util.ArrayList; +import java.util.Arrays; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PersonalizedInsightDataCollectorTest { + + @Mock VoteRepository voteRepository; + @Mock VoteOptionRepository voteOptionRepository; + @Mock VoteParticipationRepository voteParticipationRepository; + @Mock UserRepository userRepository; + + PersonalizedInsightDataCollector collector; + + static final Clock CLOCK = Clock.fixed(Instant.parse("2025-06-01T12:00:00Z"), ZoneOffset.UTC); + + Vote vote; + VoteOption optionA; + VoteOption optionB; + User maleUser; + User femaleUser; + + @BeforeEach + void setUp() { + collector = new PersonalizedInsightDataCollector( + voteRepository, voteOptionRepository, voteParticipationRepository, userRepository, CLOCK); + + Clock pastClock = Clock.fixed(Instant.parse("2025-05-30T00:00:00Z"), ZoneOffset.UTC); + vote = Vote.create("짜장면 vs 짬뽕", null, "thumb.png", null, Duration.ofHours(24), pastClock); + + optionA = createOption(vote, "짜장면", 0, 10L); + optionB = createOption(vote, "짬뽕", 1, 20L); + + maleUser = createUser(1L, Gender.MALE, Year.of(2000)); + femaleUser = createUser(2L, Gender.FEMALE, Year.of(1995)); + } + + VoteOption createOption(Vote vote, String label, int position, Long id) { + VoteOption option = VoteOption.of(vote, label, position); + try { + var idField = option.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(option, id); + } catch (Exception e) { + throw new RuntimeException(e); + } + return option; + } + + User createUser(Long id, Gender gender, Year birthYear) { + User user = User.createWithEmail("test" + id + "@test.com"); + try { + var idField = user.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, id); + + var genderField = user.getClass().getDeclaredField("gender"); + genderField.setAccessible(true); + genderField.set(user, gender); + + var birthField = user.getClass().getDeclaredField("birthYear"); + birthField.setAccessible(true); + birthField.set(user, birthYear); + } catch (Exception e) { + throw new RuntimeException(e); + } + return user; + } + + @Nested + @DisplayName("collect") + class Collect { + + @Test + @DisplayName("기본 투표 통계 수집") + void collectsBasicVoteStatistics() { + // given + Long voteId = 1L, userId = 1L, selectedOptionId = 10L; + + given(voteRepository.findById(voteId)).willReturn(Optional.of(vote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(voteId)).willReturn(List.of(optionA, optionB)); + given(voteParticipationRepository.countByVoteId(voteId)).willReturn(100L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 10L)).willReturn(60L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 20L)).willReturn(40L); + given(voteParticipationRepository.findGenderDistributionByVote(voteId)) + .willReturn(List.of(new GenderCount(Gender.MALE, 50L), new GenderCount(Gender.FEMALE, 50L))); + given(voteParticipationRepository.findAllUserIdsByVoteId(voteId)).willReturn(List.of(1L, 2L)); + given(userRepository.findAllById(anyList())).willReturn(List.of(maleUser, femaleUser)); + given(userRepository.findById(userId)).willReturn(Optional.of(maleUser)); + given(voteParticipationRepository.countByVoteIdAndOptionIdAndGender(voteId, selectedOptionId, Gender.MALE)) + .willReturn(40L); + given(voteParticipationRepository.countByVoteIdAndGender(voteId, Gender.MALE)).willReturn(50L); + given(voteParticipationRepository.findOptionCountsByVoteIdAndGender(voteId, Gender.MALE)) + .willReturn(List.of(new Object[]{10L, 40L})); + given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(voteId, selectedOptionId)) + .willReturn(List.of(1L)); + given(voteParticipationRepository.findByVoteIdAndUserId(voteId, 1L)) + .willReturn(Optional.of(VoteParticipation.ofMember(voteId, 1L, selectedOptionId))); + + // when + PersonalizedVoteInsightRequest request = collector.collect(voteId, userId, selectedOptionId); + + // then + assertThat(request.voteTitle()).isEqualTo("짜장면 vs 짬뽕"); + assertThat(request.optionALabel()).isEqualTo("짜장면"); + assertThat(request.optionACount()).isEqualTo(60); + assertThat(request.optionARatio()).isEqualTo(60); + assertThat(request.optionBLabel()).isEqualTo("짬뽕"); + assertThat(request.optionBCount()).isEqualTo(40); + assertThat(request.totalParticipants()).isEqualTo(100); + } + + @Test + @DisplayName("사용자 프로필 정보 수집") + void collectsUserProfile() { + // given + Long voteId = 1L, userId = 1L, selectedOptionId = 10L; + + given(voteRepository.findById(voteId)).willReturn(Optional.of(vote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(voteId)).willReturn(List.of(optionA, optionB)); + given(voteParticipationRepository.countByVoteId(voteId)).willReturn(100L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 10L)).willReturn(60L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 20L)).willReturn(40L); + given(voteParticipationRepository.findGenderDistributionByVote(voteId)).willReturn(List.of()); + given(voteParticipationRepository.findAllUserIdsByVoteId(voteId)).willReturn(List.of()); + given(userRepository.findAllById(anyList())).willReturn(List.of()); + given(userRepository.findById(userId)).willReturn(Optional.of(maleUser)); + given(voteParticipationRepository.countByVoteIdAndOptionIdAndGender(voteId, selectedOptionId, Gender.MALE)) + .willReturn(30L); + given(voteParticipationRepository.countByVoteIdAndGender(voteId, Gender.MALE)).willReturn(50L); + given(voteParticipationRepository.findOptionCountsByVoteIdAndGender(voteId, Gender.MALE)) + .willReturn(List.of(new Object[]{10L, 30L})); + given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(voteId, selectedOptionId)) + .willReturn(List.of(1L)); + given(voteParticipationRepository.findByVoteIdAndUserId(voteId, 1L)) + .willReturn(Optional.of(VoteParticipation.ofMember(voteId, 1L, selectedOptionId))); + + // when + PersonalizedVoteInsightRequest request = collector.collect(voteId, userId, selectedOptionId); + + // then + assertThat(request.userGender()).isEqualTo("MALE"); + assertThat(request.userAgeGroup()).isEqualTo("20s"); // 2000년생 → 25세 → 20대 + assertThat(request.userSelectedOption()).isEqualTo("짜장면"); + } + + @Test + @DisplayName("같은 성별 비율 계산") + void calculatesSameGenderRatio() { + // given + Long voteId = 1L, userId = 1L, selectedOptionId = 10L; + + given(voteRepository.findById(voteId)).willReturn(Optional.of(vote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(voteId)).willReturn(List.of(optionA, optionB)); + given(voteParticipationRepository.countByVoteId(voteId)).willReturn(100L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 10L)).willReturn(60L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 20L)).willReturn(40L); + given(voteParticipationRepository.findGenderDistributionByVote(voteId)).willReturn(List.of()); + given(voteParticipationRepository.findAllUserIdsByVoteId(voteId)).willReturn(List.of()); + given(userRepository.findAllById(anyList())).willReturn(List.of()); + given(userRepository.findById(userId)).willReturn(Optional.of(maleUser)); + // 남성 50명 중 35명이 같은 옵션 선택 → 70% + given(voteParticipationRepository.countByVoteIdAndOptionIdAndGender(voteId, selectedOptionId, Gender.MALE)) + .willReturn(35L); + given(voteParticipationRepository.countByVoteIdAndGender(voteId, Gender.MALE)).willReturn(50L); + given(voteParticipationRepository.findOptionCountsByVoteIdAndGender(voteId, Gender.MALE)) + .willReturn(List.of(new Object[]{10L, 35L})); + given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(voteId, selectedOptionId)) + .willReturn(List.of(1L)); + given(voteParticipationRepository.findByVoteIdAndUserId(voteId, 1L)) + .willReturn(Optional.of(VoteParticipation.ofMember(voteId, 1L, selectedOptionId))); + + // when + PersonalizedVoteInsightRequest request = collector.collect(voteId, userId, selectedOptionId); + + // then + assertThat(request.sameGenderRatio()).isEqualTo(70); + assertThat(request.sameGenderMajorityOption()).isEqualTo("짜장면"); + } + + @Test + @DisplayName("사용자 정보 없으면 null 반환") + void returnsNullForMissingUserInfo() { + // given + Long voteId = 1L, userId = 999L, selectedOptionId = 10L; + + given(voteRepository.findById(voteId)).willReturn(Optional.of(vote)); + given(voteOptionRepository.findByVoteIdOrderByPosition(voteId)).willReturn(List.of(optionA, optionB)); + given(voteParticipationRepository.countByVoteId(voteId)).willReturn(100L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 10L)).willReturn(60L); + given(voteParticipationRepository.countByVoteIdAndOptionId(voteId, 20L)).willReturn(40L); + given(voteParticipationRepository.findGenderDistributionByVote(voteId)).willReturn(List.of()); + given(voteParticipationRepository.findAllUserIdsByVoteId(voteId)).willReturn(List.of()); + given(userRepository.findAllById(anyList())).willReturn(List.of()); + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when + PersonalizedVoteInsightRequest request = collector.collect(voteId, userId, selectedOptionId); + + // then + assertThat(request.userGender()).isNull(); + assertThat(request.userAgeGroup()).isNull(); + assertThat(request.sameGenderRatio()).isEqualTo(0); + assertThat(request.sameAgeGroupRatio()).isEqualTo(0); + } + } +} diff --git a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatMessageEventListenerTest.java b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatMessageEventListenerTest.java index 2df7faea..db2a599f 100644 --- a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatMessageEventListenerTest.java +++ b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatMessageEventListenerTest.java @@ -10,7 +10,6 @@ import com.ject.vs.user.domain.ImageColor; import com.ject.vs.user.domain.User; import com.ject.vs.user.port.in.UserQueryUseCase; -import com.ject.vs.vote.domain.VoteOption; import com.ject.vs.vote.domain.VoteOptionCode; import com.ject.vs.vote.port.in.VoteParticipationQueryUseCase; import com.ject.vs.vote.port.in.VoteQueryUseCase; @@ -64,9 +63,8 @@ class ChatMessageEventListenerTest { void setUpDefaults() { lenient().when(userQueryUseCase.getUser(any())).thenReturn(mock(User.class)); - VoteOption dummyOption = mock(VoteOption.class); - lenient().when(dummyOption.getCode()).thenReturn(VoteOptionCode.A); - lenient().when(voteQueryUseCase.getSelectedOption(anyLong(), anyLong())).thenReturn(dummyOption); + lenient().when(voteQueryUseCase.findSelectedOptionCode(anyLong(), anyLong())) + .thenReturn(Optional.of(VoteOptionCode.A)); } @Nested diff --git a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketE2ETest.java b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketE2ETest.java index a2525cc9..109be5f0 100644 --- a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketE2ETest.java +++ b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketE2ETest.java @@ -4,7 +4,7 @@ import com.ject.vs.chat.adapter.web.dto.SendMessageRequest; import com.ject.vs.chat.port.in.dto.MessageResult; import com.ject.vs.chat.port.in.dto.UnreadPayload; -import com.ject.vs.notification.port.out.PushSenderPort; +import com.ject.vs.image.port.ImageService; import com.ject.vs.user.domain.User; import com.ject.vs.user.domain.UserRepository; import com.ject.vs.util.CookieUtil; @@ -47,6 +47,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ChatWebSocketE2ETest { + @MockitoBean + private ImageService imageService; @LocalServerPort private int port; diff --git a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketIntegrationTest.java b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketIntegrationTest.java index 54e8e265..725f5022 100644 --- a/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketIntegrationTest.java +++ b/src/unitTest/java/com/ject/vs/chat/adapter/event/ChatWebSocketIntegrationTest.java @@ -7,7 +7,7 @@ import com.ject.vs.chat.domain.ChatRoomUnreadRepository; import com.ject.vs.chat.port.in.dto.MessageResult; import com.ject.vs.chat.port.in.dto.UnreadPayload; -import com.ject.vs.notification.port.out.PushSenderPort; +import com.ject.vs.image.port.ImageService; import com.ject.vs.user.domain.User; import com.ject.vs.user.domain.UserRepository; import com.ject.vs.util.CookieUtil; @@ -17,7 +17,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.simp.stomp.StompFrameHandler; @@ -47,8 +46,8 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ChatWebSocketIntegrationTest { - @MockBean - PushSenderPort pushSenderPort; + @MockitoBean + private ImageService imageService; @LocalServerPort private int port; diff --git a/src/unitTest/java/com/ject/vs/chat/port/ChatServiceTest.java b/src/unitTest/java/com/ject/vs/chat/port/ChatServiceTest.java index 08635c65..5449ec90 100644 --- a/src/unitTest/java/com/ject/vs/chat/port/ChatServiceTest.java +++ b/src/unitTest/java/com/ject/vs/chat/port/ChatServiceTest.java @@ -6,14 +6,15 @@ import com.ject.vs.chat.domain.ChatRoomUnreadRepository; import com.ject.vs.chat.exception.ChatForbiddenException; import com.ject.vs.chat.exception.InvalidMessageException; +import com.ject.vs.chat.port.in.dto.ChatListItemResult; import com.ject.vs.chat.port.in.dto.MarkAsReadCommand; import com.ject.vs.chat.port.in.dto.MessagePageResult; import com.ject.vs.chat.port.in.dto.MessageResult; import com.ject.vs.chat.port.in.dto.SendMessageCommand; +import com.ject.vs.vote.domain.VoteStatus; import com.ject.vs.user.domain.ImageColor; import com.ject.vs.user.domain.User; import com.ject.vs.user.port.in.UserQueryUseCase; -import com.ject.vs.vote.domain.VoteOption; import com.ject.vs.vote.domain.VoteOptionCode; import com.ject.vs.vote.port.in.VoteParticipationQueryUseCase; import com.ject.vs.vote.port.in.VoteQueryUseCase; @@ -27,6 +28,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageRequest; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -58,9 +60,6 @@ class ChatServiceTest { @Mock private UserQueryUseCase userQueryUseCase; - @Mock - private VoteOption selectedOption; - @Nested class sendMessage { @@ -95,8 +94,7 @@ class sendMessage { given(sender.getNickname()).willReturn("테스트유저"); given(sender.getImageColor()).willReturn(ImageColor.GREEN); given(userQueryUseCase.getUser(2L)).willReturn(sender); - given(voteQueryUseCase.getSelectedOption(1L, 2L)).willReturn(selectedOption); - given(selectedOption.getCode()).willReturn(VoteOptionCode.A); + given(voteQueryUseCase.findSelectedOptionCode(1L, 2L)).willReturn(Optional.of(VoteOptionCode.A)); // when MessageResult result = chatService.sendMessage(new SendMessageCommand(1L, 2L, "hello")); @@ -139,6 +137,57 @@ class markAsRead { } } + @Nested + class getChatList { + + @Test + void 최근_메시지_시간_기준_내림차순으로_정렬한다() { + // given + Long userId = 1L; + given(voteParticipationQueryUseCase.findAllVoteIdsByUserId(userId)).willReturn(List.of(10L, 20L, 30L)); + given(voteQueryUseCase.findAllVoteIdsByStatus(List.of(10L, 20L, 30L), VoteStatus.ONGOING)) + .willReturn(List.of(10L, 20L, 30L)); + + Instant endAt = Instant.parse("2026-12-31T00:00:00Z"); + given(voteQueryUseCase.getVoteChatSummary(10L)) + .willReturn(new VoteQueryUseCase.VoteChatSummary(10L, "t10", null, VoteStatus.ONGOING, endAt, "A", "B")); + given(voteQueryUseCase.getVoteChatSummary(20L)) + .willReturn(new VoteQueryUseCase.VoteChatSummary(20L, "t20", null, VoteStatus.ONGOING, endAt, "A", "B")); + given(voteQueryUseCase.getVoteChatSummary(30L)) + .willReturn(new VoteQueryUseCase.VoteChatSummary(30L, "t30", null, VoteStatus.ONGOING, endAt, "A", "B")); + + given(voteParticipationQueryUseCase.countParticipantsByVoteId(any())).willReturn(1L); + given(chatRoomUnreadRepository.findByIdUserIdAndIdVoteId(eq(userId), any())).willReturn(Optional.empty()); + given(chatMessageRepository.countByVoteId(any())).willReturn(0L); + + ChatMessage oldMsg = mock(ChatMessage.class); + ChatMessage midMsg = mock(ChatMessage.class); + ChatMessage newMsg = mock(ChatMessage.class); + + given(oldMsg.getContent()).willReturn("old"); + given(midMsg.getContent()).willReturn("mid"); + given(newMsg.getContent()).willReturn("new"); + given(oldMsg.getCreatedAt()).willReturn(Instant.parse("2026-01-01T00:00:00Z")); + given(midMsg.getCreatedAt()).willReturn(Instant.parse("2026-01-02T00:00:00Z")); + given(newMsg.getCreatedAt()).willReturn(Instant.parse("2026-01-03T00:00:00Z")); + + given(chatMessageRepository.findFirstByVoteIdOrderByIdDesc(10L)).willReturn(Optional.of(oldMsg)); + given(chatMessageRepository.findFirstByVoteIdOrderByIdDesc(20L)).willReturn(Optional.of(midMsg)); + given(chatMessageRepository.findFirstByVoteIdOrderByIdDesc(30L)).willReturn(Optional.of(newMsg)); + + // when + List result = chatService.getChatList(userId, VoteStatus.ONGOING); + + // then + assertThat(result).extracting(ChatListItemResult::voteId).containsExactly(30L, 20L, 10L); + assertThat(result).extracting(ChatListItemResult::lastMessageAt) + .containsExactly( + Instant.parse("2026-01-03T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z"), + Instant.parse("2026-01-01T00:00:00Z")); + } + } + @Nested class getMessages { @@ -152,14 +201,14 @@ class getMessages { given(sender.getNickname()).willReturn(""); given(sender.getImageColor()).willReturn(ImageColor.GREEN); given(userQueryUseCase.getUser(2L)).willReturn(sender); - given(voteQueryUseCase.getSelectedOption(1L, 2L)).willReturn(selectedOption); - given(selectedOption.getCode()).willReturn(null); + given(voteQueryUseCase.findSelectedOptionCode(1L, 2L)).willReturn(Optional.empty()); // when MessagePageResult result = chatService.getMessages(1L, 2L, null, 30); // then assertThat(result.messages()).hasSize(1); + assertThat(result.messages().getFirst().senderVoteOption()).isNull(); assertThat(result.hasNext()).isFalse(); verify(chatMessageRepository).findAllByVoteIdOrderByIdDesc(eq(1L), any(PageRequest.class)); } @@ -174,8 +223,7 @@ class getMessages { given(sender.getNickname()).willReturn(""); given(sender.getImageColor()).willReturn(ImageColor.GREEN); given(userQueryUseCase.getUser(2L)).willReturn(sender); - given(voteQueryUseCase.getSelectedOption(1L, 2L)).willReturn(selectedOption); - given(selectedOption.getCode()).willReturn(null); + given(voteQueryUseCase.findSelectedOptionCode(1L, 2L)).willReturn(Optional.of(VoteOptionCode.B)); // when MessagePageResult result = chatService.getMessages(1L, 2L, 100L, 30); diff --git a/src/unitTest/java/com/ject/vs/config/TestPropertiesConfig.java b/src/unitTest/java/com/ject/vs/config/TestPropertiesConfig.java index 6bfe2993..f91894b3 100644 --- a/src/unitTest/java/com/ject/vs/config/TestPropertiesConfig.java +++ b/src/unitTest/java/com/ject/vs/config/TestPropertiesConfig.java @@ -1,9 +1,11 @@ package com.ject.vs.config; -import org.springframework.boot.test.context.TestConfiguration; +import com.ject.vs.image.port.ImageService; +import org.mockito.Mockito; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; -@TestConfiguration +@Configuration public class TestPropertiesConfig { @Bean @@ -25,4 +27,9 @@ public OAuth2Properties oauth2Properties() { "http://localhost:3000/extra-info" ); } + + @Bean + public ImageService imageService() { + return Mockito.mock(ImageService.class); + } } \ No newline at end of file diff --git a/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java b/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java index c1fcc00c..f810bdf4 100644 --- a/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java +++ b/src/unitTest/java/com/ject/vs/vote/port/ImmersiveVoteQueryServiceTest.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.chat.domain.ChatMessageRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveFeedResult; import com.ject.vs.vote.port.in.ImmersiveVoteQueryUseCase.ImmersiveLiveResult; @@ -41,6 +42,7 @@ class ImmersiveVoteQueryServiceTest { @Mock private VoteOptionRepository voteOptionRepository; @Mock private VoteParticipationRepository voteParticipationRepository; @Mock private VoteEmojiReactionRepository emojiReactionRepository; + @Mock private ChatMessageRepository chatMessageRepository; @Mock private Clock clock; private Vote makeVote(Duration duration) { @@ -139,6 +141,34 @@ void setUp() { assertThat(result.items().get(0).mySelectedOptionId()).isEqualTo(77L); } + @Test + void imageUrl_있으면_그대로_노출() { + Vote vote = makeVote(Duration.ofHours(24)); // thumbnailUrl="t", imageUrl="img.png" + given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of()); + given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null); + + assertThat(result.items().get(0).imageUrl()).isEqualTo("img.png"); + } + + @Test + void imageUrl_null이면_thumbnailUrl로_폴백() { + Vote vote = Vote.create("몰입", null, "thumb.png", null, Duration.ofHours(24), FIXED_CLOCK); + given(voteRepository.findByEndAtAfterOrderByIdDesc(any(), any())) + .willReturn(new SliceImpl<>(List.of(vote), PageRequest.of(0, 10), false)); + given(voteOptionRepository.findByVoteIdOrderByPosition(any())).willReturn(List.of()); + given(emojiReactionRepository.countByEmojiForVote(any())).willReturn(List.of()); + given(voteParticipationRepository.countByVoteId(any())).willReturn(0L); + + ImmersiveFeedResult result = service.getFeed(null, null, 10, null, null); + + assertThat(result.items().get(0).imageUrl()).isEqualTo("thumb.png"); + } + @Test void 미참여시_mySelectedOptionId_null() { Vote vote = makeVote(Duration.ofHours(24)); diff --git a/src/unitTest/java/com/ject/vs/vote/port/VoteDetailQueryServiceTest.java b/src/unitTest/java/com/ject/vs/vote/port/VoteDetailQueryServiceTest.java index 70c5e598..cdd69e62 100644 --- a/src/unitTest/java/com/ject/vs/vote/port/VoteDetailQueryServiceTest.java +++ b/src/unitTest/java/com/ject/vs/vote/port/VoteDetailQueryServiceTest.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.chat.domain.ChatMessageRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.VoteNotFoundException; import com.ject.vs.vote.port.VoteDetailQueryService.VoteDetailResult; @@ -31,6 +32,7 @@ class VoteDetailQueryServiceTest { @Mock VoteOptionRepository voteOptionRepository; @Mock VoteParticipationRepository voteParticipationRepository; @Mock VoteEmojiReactionRepository emojiReactionRepository; + @Mock ChatMessageRepository chatMessageRepository; private VoteDetailQueryService service; @@ -42,7 +44,7 @@ class VoteDetailQueryServiceTest { @BeforeEach void setUp() { service = new VoteDetailQueryService( - voteRepository, voteOptionRepository, voteParticipationRepository, emojiReactionRepository, CLOCK); + voteRepository, voteOptionRepository, voteParticipationRepository, emojiReactionRepository, chatMessageRepository, CLOCK); // 진행중 투표 (현재 시간 기준 24시간 후 종료) Clock recentClock = Clock.fixed(Instant.parse("2025-06-01T00:00:00Z"), ZoneOffset.UTC); diff --git a/src/unitTest/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java b/src/unitTest/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java index 4d225fb3..72da0324 100644 --- a/src/unitTest/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java +++ b/src/unitTest/java/com/ject/vs/vote/port/VoteResultQueryServiceTest.java @@ -1,5 +1,6 @@ package com.ject.vs.vote.port; +import com.ject.vs.ai.port.PersonalizedAiInsightService; import com.ject.vs.user.domain.UserRepository; import com.ject.vs.vote.domain.*; import com.ject.vs.vote.exception.VoteNotFoundException; @@ -21,8 +22,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; +import com.ject.vs.ai.port.in.AiInsightUseCase.AiInsightResult; + @ExtendWith(MockitoExtension.class) class VoteResultQueryServiceTest { @@ -30,6 +34,7 @@ class VoteResultQueryServiceTest { @Mock VoteOptionRepository voteOptionRepository; @Mock VoteParticipationRepository voteParticipationRepository; @Mock UserRepository userRepository; + @Mock PersonalizedAiInsightService personalizedAiInsightService; private VoteResultQueryService service; @@ -41,7 +46,7 @@ class VoteResultQueryServiceTest { @BeforeEach void setUp() { service = new VoteResultQueryService( - voteRepository, voteOptionRepository, voteParticipationRepository, userRepository, CLOCK); + voteRepository, voteOptionRepository, voteParticipationRepository, userRepository, personalizedAiInsightService, CLOCK); Clock pastClock = Clock.fixed(Instant.parse("2025-05-30T00:00:00Z"), ZoneOffset.UTC); endedVote = Vote.create("제목", null, "thumb.png", null, @@ -137,9 +142,8 @@ class getResult { } @Test - void ai_insight_있으면_available_true() { + void 참여자는_개인화_AI_인사이트를_받는다() { given(voteRepository.findById(1L)).willReturn(Optional.of(endedVote)); - endedVote.cacheAiInsight("헤드라인", "바디"); given(voteOptionRepository.findByVoteIdOrderByPosition(1L)).willReturn(List.of()); given(voteParticipationRepository.countByVoteId(1L)).willReturn(5L); @@ -152,11 +156,16 @@ class getResult { given(voteParticipationRepository.findUserIdsByVoteIdAndOptionId(1L, 10L)).willReturn(List.of()); given(userRepository.findById(1L)).willReturn(Optional.empty()); + // PersonalizedAiInsightService Mock + AiInsightResult mockInsight = new AiInsightResult("개인화 헤드라인", "개인화 바디"); + given(personalizedAiInsightService.getOrGenerate(1L, 1L, 10L)) + .willReturn(Optional.of(mockInsight)); + VoteResultDetail result = service.getResult(1L, 1L); assertThat(result.aiInsight().available()).isTrue(); - assertThat(result.aiInsight().headline()).isEqualTo("헤드라인"); - assertThat(result.aiInsight().body()).isEqualTo("바디"); + assertThat(result.aiInsight().headline()).isEqualTo("개인화 헤드라인"); + assertThat(result.aiInsight().body()).isEqualTo("개인화 바디"); } }