diff --git a/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md b/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md index f6bacc74..015faf73 100644 --- a/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md +++ b/docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md @@ -34,6 +34,7 @@ - New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다. - 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%. - 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%. +- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다. - 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다. - 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다. - `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다. @@ -608,9 +609,32 @@ interface AudioRecommendationQueryPort { - `git diff --check`: 출력 없음. - `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인. +### Phase 9: 최근 댓글 많은 오디오 크리에이터 중복 저장 정책 + +- [x] **Task 9.1: 같은 크리에이터의 최근 댓글 많은 오디오 후보는 최고 점수 1개만 저장** + - 작성일: 2026-07-12 + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` + - RED: 같은 크리에이터의 오디오 2개가 최근 댓글 많은 오디오 스냅샷 후보에 들어올 때, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots` 또는 `DefaultAudioRecommendationQueryRepository.findMostCommentedSnapshots` 결과에 더 높은 최근 댓글 많은 오디오 점수의 1개만 남는 repository/service 테스트를 먼저 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest` + - GREEN: repository 후보 산정 SQL 또는 service 후보 선택 단계에서 `creatorId`별 최고 점수 후보만 남기고 `RecommendationSnapshotPort.replaceSnapshots(...)`에는 중복 제거가 끝난 후보만 전달한다. + - REFACTOR: 점수 동률과 `randomTieBreaker` 정렬 정책이 기존 `score desc`, `randomTieBreaker asc` 규칙을 유지하는지 회귀 테스트로 확인하고, `MOST_COMMENTED_LIMIT` 최대 5개 정책과 SAFE/ALL visibility 조건을 변경하지 않는다. + - 기대 결과: `MOST_COMMENTED_AUDIO_SAFE/ALL` 스냅샷에는 같은 크리에이터의 오디오가 최대 1개만 저장되고, 공개 API 응답 스키마는 변경되지 않는다. + - 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다. + - 2026-07-12 문서화 기록: 이번 변경은 PRD/plan-task 문서만 갱신했으며 source code와 test code는 아직 변경하지 않았다. 따라서 위 RED/GREEN/REFACTOR 구현 task는 미완료 상태로 남긴다. + - 2026-07-12 구현 검증 기록: + - RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator'` 실행 결과 `expected: <[2, 3]> but was: <[2, 1, 3]>`로 실패해 같은 크리에이터의 낮은 점수 후보가 함께 반환되는 기존 동작을 확인했다. + - GREEN: `DefaultAudioRecommendationQueryRepository.findMostCommentedSnapshots(...)`가 `creatorId`별 `row_number()`로 최고 점수 후보 1개만 남긴 뒤 최종 limit을 적용하도록 수정했다. 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다. + - 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 모두 `BUILD SUCCESSFUL`로 통과했다. + ### Phase 8: 회귀 검증과 문서 기록 -- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행** +- [x] **Task 8.1: 전체 관련 테스트와 ktlint 실행** - Files: - Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` - Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md` @@ -623,8 +647,14 @@ interface AudioRecommendationQueryPort { - Run: `./gradlew ktlintCheck` - 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다. - 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다. + - 2026-07-12 Phase 9 이후 검증 기록: + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: `BUILD SUCCESSFUL`. + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL`. + - `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`: `BUILD SUCCESSFUL`. + - `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`. -- [ ] **Task 8.2: 문서/스키마 영향 최종 확인** +- [x] **Task 8.2: 문서/스키마 영향 최종 확인** - Files: - Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` - Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md` @@ -638,6 +668,12 @@ interface AudioRecommendationQueryPort { - Run: `./gradlew tasks --all` - 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다. - 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다. + - 2026-07-12 Phase 9 이후 검증 기록: + - `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`로 endpoint, DTO, section enum 참조가 문서/코드/테스트에 남아 있음을 확인했다. + - `rg -n "api\.audio\.recommendation|v2\.audio\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`로 production/test의 최종 추천 API 패키지가 `content.recommendation` 기준임을 확인했다. + - `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2` 실행 결과 출력 없음으로 v2 경로의 구 성인 콘텐츠 정책 직접 사용이 없음을 확인했다. + - `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin` 실행 결과 기존 비-v2 콘텐츠/테스트 경로의 레거시 사용만 남아 있음을 확인했다. + - `./gradlew tasks --all`: `BUILD SUCCESSFUL`. --- @@ -682,3 +718,6 @@ interface AudioRecommendationQueryPort { - 2026-06-25 후속 보정: `DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility`의 score 비교 실패 원인은 repository native SQL의 `timestampdiff(day, c.release_date, :snapshotAt)` 최신성 계산이 DB 날짜 경계 기준에 의존해 `AudioRecommendationScorePolicy`의 24시간 경과 기준 `ChronoUnit.DAYS` 계산과 어긋날 수 있는 점으로 확인했다. `DefaultAudioRecommendationQueryRepository`의 New & Hot/추천 오디오 공개일 최신성 계산을 `floor(timestampdiff(hour, c.release_date, :snapshotAt) / 24)`로 변경해 Kotlin 정책과 일치시켰고, `SAFE` 성인 콘텐츠 제외 조건은 기존 `(:includeAdult = true or c.is_adult = false)` 구현이 올바른 것으로 확인했다. 검증은 `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility'`, `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicyTest'`, `./gradlew ktlintCheck` 모두 `BUILD SUCCESSFUL`로 완료했다. - 2026-07-12 후속 정책 변경: 콘텐츠 추천 탭의 `latestAudios`, `freeAudios`, `pointAudios` 기본 노출 수를 각각 15개로 확정했다. PRD/plan-task의 섹션별 기본 노출 수와 관련 task 설명을 15개 정책으로 갱신했고, service 테스트는 `AudioRecommendationQueryService` 상수 기준으로 limit 전달을 검증하도록 정리했다. 검증은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon tasks --all` 모두 `BUILD SUCCESSFUL`로 완료했다. 단, `tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인 후 재실행했다. - 2026-07-12 후속 정책 변경: 콘텐츠 추천 탭의 `recommendedAudios`와 `RECOMMENDED_AUDIO_SAFE/ALL` 스냅샷 후보 저장 수를 최대 20개로 확정했다. PRD의 섹션별 기본 노출 수와 추천 오디오 요구사항, plan-task의 개요와 Task 4.3 후보 산정 설명을 20개 정책으로 갱신했다. 해당 개수는 정책값으로 이후 변경될 수 있으므로 테스트는 `assertEquals(20, RECOMMENDED_AUDIO_LIMIT)`처럼 숫자 자체를 고정하지 않고, 기존 service/refresh 테스트에서 `RECOMMENDED_AUDIO_LIMIT`가 snapshot 조회와 후보 산정 port 호출에 전달되는 흐름을 검증하는 방식으로 유지했다. 검증은 이전 개수 정책 문구 잔존 검색 결과 없음, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` `BUILD SUCCESSFUL`, `git diff --check` 출력 없음, `./gradlew --no-daemon tasks --all` `BUILD SUCCESSFUL`로 완료했다. 단, `tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인 후 재실행했다. +- 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다. + +- 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다. diff --git a/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md b/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md index 093fd49c..3a6c4ba6 100644 --- a/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md +++ b/docs/20260623_메인_콘텐츠_추천_탭_API/prd.md @@ -146,6 +146,7 @@ - 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다. - 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다. - 최대 5개를 표시한다. +- 스냅샷 저장 후보에 같은 크리에이터의 오디오가 여러 개 있으면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다. - 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다. #### Edge Cases @@ -285,6 +286,7 @@ data class CommentedAudioResponse( - New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다. - 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다. - 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다. +- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다. --- diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md index 759ee98b..3f055e85 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md @@ -18,7 +18,7 @@ - 응답 wrapper: `ApiResponse.ok(...)` - `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다. - 섹션별 기본 노출 수: - - `followingCreators`: 최신 팔로우순 20개 + - `followingCreators`: 오래된 팔로우순 20개 - `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개 - `recentChats`: DM/AI 채팅 최신순 10개 - `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개 @@ -505,7 +505,7 @@ data class HomeFollowingNewsInboxRecord( - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt` - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt` - - RED: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다. + - RED: 활성 팔로우/활성 크리에이터만 오래된 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다. - RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다. - 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인. - GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다. @@ -859,3 +859,9 @@ data class HomeFollowingNewsInboxRecord( - 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`. - 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. - 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다. + +- 2026-07-12 팔로잉 크리에이터 정렬 변경 영향 확인: + - `DefaultHomeFollowingQueryRepository.findFollowingCreators`의 정렬을 `creatorFollowing.createdAt.desc()`에서 `creatorFollowing.createdAt.asc()`로 변경한 계약에 맞춰 PRD/계획 문서의 `followingCreators` 정렬 설명을 오래된 팔로우순으로 갱신했다. + - 직접 검색 결과 정렬 계약을 명시한 위치는 `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`, `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`, `DefaultHomeFollowingQueryRepositoryTest.shouldFindActiveFollowingCreatorsByOldestFollowOrder`였고, 호출부는 `HomeFollowingQueryService`가 반환 순서를 그대로 조립하는 구조로 확인했다. + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`. + - `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md index 98539465..6270d69e 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md @@ -19,7 +19,7 @@ - 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다. - 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다. - 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다. -- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다. +- 사용자가 팔로우한 크리에이터 목록을 오래된 팔로우순 20개 응답한다. - 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다. - DM/AI 채팅방 중 최신 대화순 10개를 응답한다. - 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다. @@ -55,7 +55,7 @@ --- ## 6. User Stories -- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다. +- 사용자는 내가 팔로우한 크리에이터 목록을 오래 팔로우한 순서로 보고 싶다. - 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다. - 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다. - 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다. @@ -90,7 +90,7 @@ ### Feature B. 팔로잉 크리에이터 #### Requirements -- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다. +- 사용자가 팔로우한 활성 크리에이터를 오래된 팔로우순으로 최대 20개 조회한다. - 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다. - 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다. - 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다. diff --git a/docs/20260712_DM룸_푸시문구_수정/plan-task.md b/docs/20260712_DM룸_푸시문구_수정/plan-task.md new file mode 100644 index 00000000..78263952 --- /dev/null +++ b/docs/20260712_DM룸_푸시문구_수정/plan-task.md @@ -0,0 +1,13 @@ +# DM룸 푸시문구 수정 구현 계획 + +## 시나리오 +1. Happy path: 상대방이 DM Room에 입장하지 않은 상태에서 WebSocket 텍스트 메시지를 보내면 `FcmEvent.messageKey`가 `message.fcm.dm_received`이다. +2. Edge: `message.fcm.dm_received`가 KO/EN/JA에서 조회되고, 기존 `message.fcm.text_received` 한국어 문구는 그대로다. +3. Adjacent regression: DM 음성 메시지 푸시는 계속 `message.fcm.voice_received`를 사용한다. + +## 작업 +1. RED: `SodaMessageSourceTest`에 DM 전용 키 다국어 조회와 기존 키 보존 테스트를 추가한다. +2. RED: `UserCreatorChatServiceTest`에 DM 텍스트 푸시 키와 음성 푸시 키 회귀 assertion을 추가한다. +3. GREEN: `SodaMessageSource`에 `message.fcm.dm_received` 다국어 메시지를 추가한다. +4. GREEN: `UserCreatorChatService.publishMessagePush`의 텍스트 분기만 새 키로 바꾼다. +5. VERIFY: 관련 테스트와 lint를 실행한다. diff --git a/docs/20260712_DM룸_푸시문구_수정/prd.md b/docs/20260712_DM룸_푸시문구_수정/prd.md new file mode 100644 index 00000000..7c552f70 --- /dev/null +++ b/docs/20260712_DM룸_푸시문구_수정/prd.md @@ -0,0 +1,20 @@ +# DM룸 푸시문구 수정 PRD + +## 목적 +DM Room에서 텍스트 메시지를 보냈을 때 상대방이 해당 채팅방에 입장하지 않은 상태라면 발송되는 푸시 본문을 DM 전용 문구로 분리한다. + +## 요구사항 +- 기존 `message.fcm.text_received` 메시지는 변경하지 않는다. +- DM 텍스트 푸시는 새 메시지 키를 사용한다. +- 새 한국어 문구는 기존 문구의 `문자메시지가`를 `DM이`로 바꾼다. +- 새 메시지 키는 한국어, 영어, 일본어를 모두 제공한다. +- DM 음성 메시지 푸시는 기존 `message.fcm.voice_received`를 유지한다. + +## 비범위 +- FCM 발송 구조, deep link, 수신자 선정 로직은 변경하지 않는다. +- 기존 일반 메시지 푸시 문구는 변경하지 않는다. + +## 성공 기준 +- 오프라인 DM 텍스트 메시지 푸시 이벤트가 새 DM 메시지 키를 사용한다. +- `message.fcm.text_received`는 기존 값으로 남아 있다. +- 새 DM 메시지 키가 `Lang.KO`, `Lang.EN`, `Lang.JA`에서 조회된다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 3c77acd7..f8bdaaaf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1170,6 +1170,11 @@ class SodaMessageSource { Lang.EN to "You have received a text message from %s.", Lang.JA to "%sからテキストメッセージが届きました。" ), + "message.fcm.dm_received" to mapOf( + Lang.KO to "%s님으로 부터 DM이 도착했습니다.", + Lang.EN to "You have received a DM from %s.", + Lang.JA to "%sからDMが届きました。" + ), "message.fcm.voice_received" to mapOf( Lang.KO to "%s님으로 부터 음성메시지가 도착했습니다.", Lang.EN to "You have received a voice message from %s.", diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 9aaff91b..556d0d88 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -288,7 +288,8 @@ class DefaultAudioRecommendationQueryRepository( else 0.0 end * 20.0 """.trimIndent(), - requireComments = true + requireComments = true, + dedupeByCreator = true ) } @@ -325,12 +326,13 @@ class DefaultAudioRecommendationQueryRepository( limit: Int, sectionType: RecommendedSectionType, scoreExpression: String, - requireComments: Boolean = false + requireComments: Boolean = false, + dedupeByCreator: Boolean = false ): List { val commentJoin = if (requireComments) "join" else "left join" val commentRequirement = if (requireComments) "and cm.comment_count is not null" else "" - val sql = """ - select c.id, ($scoreExpression) score, rand() random_tie_breaker + val scoredSql = """ + select c.id, c.member_id creator_id, ($scoreExpression) score, rand() random_tie_breaker from content c join member creator on creator.id = c.member_id join content_theme theme on theme.id = c.theme_id @@ -372,9 +374,29 @@ class DefaultAudioRecommendationQueryRepository( and theme.is_active = true and (:includeAdult = true or c.is_adult = false) $commentRequirement - order by score desc, random_tie_breaker asc - limit :limit """.trimIndent() + val sql = if (dedupeByCreator) { + """ + select id, score, random_tie_breaker + from ( + select scored.*, row_number() over ( + partition by creator_id + order by score desc, random_tie_breaker asc + ) creator_rank + from ($scoredSql) scored + ) ranked + where creator_rank = 1 + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + } else { + """ + select id, score, random_tie_breaker + from ($scoredSql) scored + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + } return entityManager.createNativeQuery(sql) .setParameter("windowStart", windowStart) .setParameter("snapshotAt", snapshotAt) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt index 91220210..d5d33206 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -52,7 +52,7 @@ class DefaultHomeFollowingQueryRepository( creator.role.eq(MemberRole.CREATOR), notBlockedCreatorCondition(memberId, creator.id) ) - .orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc()) + .orderBy(creatorFollowing.createdAt.asc(), creatorFollowing.id.desc()) .limit(limit.toLong()) .fetch() .map { row -> diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt index dcc3d77c..db688b1a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt @@ -224,7 +224,7 @@ class UserCreatorChatService( val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) { "message.fcm.voice_received" } else { - "message.fcm.text_received" + "message.fcm.dm_received" } applicationEventPublisher.publishEvent( FcmEvent( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt index 027c79ab..eca10cc9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSourceTest.kt @@ -21,4 +21,24 @@ class SodaMessageSourceTest { sodaMessageSource.getMessage("partner.agent.ratio.not_found", Lang.JA) ) } + + @Test + fun shouldProvideDmPushMessagesAndKeepLegacyTextMessage() { + assertEquals( + "%s님으로 부터 문자메시지가 도착했습니다.", + sodaMessageSource.getMessage("message.fcm.text_received", Lang.KO) + ) + assertEquals( + "%s님으로 부터 DM이 도착했습니다.", + sodaMessageSource.getMessage("message.fcm.dm_received", Lang.KO) + ) + assertEquals( + "You have received a DM from %s.", + sodaMessageSource.getMessage("message.fcm.dm_received", Lang.EN) + ) + assertEquals( + "%sからDMが届きました。", + sodaMessageSource.getMessage("message.fcm.dm_received", Lang.JA) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 7cf054bd..504971f6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -232,6 +232,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( assertEquals("https://cdn.test/commented.png", commented.first().imageUrl) } + @Test + @DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다") + fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() { + val snapshotAt = LocalDateTime.of(2026, 7, 12, 23, 59, 59) + val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay() + val sameCreator = saveMember("dedupe-comment-creator", MemberRole.CREATOR) + val otherCreator = saveMember("dedupe-other-creator", MemberRole.CREATOR) + val writer = saveMember("dedupe-comment-writer", MemberRole.USER) + val theme = saveTheme() + val lowerScore = saveAudio(sameCreator, theme, "same-creator-lower", snapshotAt.minusDays(2)) + val higherScore = saveAudio(sameCreator, theme, "same-creator-higher", snapshotAt.minusDays(2)) + val other = saveAudio(otherCreator, theme, "other-creator", snapshotAt.minusDays(2)) + saveComment(lowerScore, writer, "lower", snapshotAt.minusHours(1)) + saveComment(higherScore, writer, "higher-1", snapshotAt.minusHours(2)) + saveComment(higherScore, writer, "higher-2", snapshotAt.minusHours(1)) + saveComment(other, writer, "other", snapshotAt.minusHours(1)) + flushAndClear() + + val snapshots = repository.findMostCommentedSnapshots( + windowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + 5 + ) + + assertEquals(listOf(higherScore.id, other.id), snapshots.map { it.targetId }) + } + @Test @DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다") fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt index ccb0b49f..e624688b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -39,8 +39,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test") @Test - @DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 최신 팔로우순으로 조회한다") - fun shouldFindActiveFollowingCreatorsByLatestFollowOrder() { + @DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 오래된 팔로우순으로 조회한다") + fun shouldFindActiveFollowingCreatorsByOldestFollowOrder() { val viewer = saveMember("following-viewer", MemberRole.USER) val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png") val inactiveCreator = saveMember("following-inactive", MemberRole.CREATOR, isActive = false) @@ -57,8 +57,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor( val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20) - assertEquals(listOf(activeCreator.id!!, olderCreator.id!!), creators.map { it.creatorId }) - assertEquals("https://cdn.test/active.png", creators.first().creatorProfileImageUrl) + assertEquals(listOf(olderCreator.id!!, activeCreator.id!!), creators.map { it.creatorId }) + assertEquals("https://cdn.test/active.png", creators.last().creatorProfileImageUrl) } @Test diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt index 41434029..997fbd94 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt @@ -197,6 +197,7 @@ class UserCreatorChatServiceTest { val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals("message.fcm.dm_received", eventCaptor.value.messageKey) assertEquals(listOf(2L), eventCaptor.value.recipients) assertEquals(null, eventCaptor.value.roomId) assertEquals(null, eventCaptor.value.messageId) @@ -229,6 +230,7 @@ class UserCreatorChatServiceTest { val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals("message.fcm.dm_received", eventCaptor.value.messageKey) assertEquals(listOf(2L), eventCaptor.value.recipients) Mockito.verifyNoInteractions(roomMessageBroker) } @@ -286,6 +288,7 @@ class UserCreatorChatServiceTest { val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java) Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) + assertEquals("message.fcm.voice_received", eventCaptor.value.messageKey) assertEquals(listOf(2L), eventCaptor.value.recipients) assertEquals(null, eventCaptor.value.roomId) assertEquals(null, eventCaptor.value.messageId)