Compare commits
6 Commits
6da2378b29
...
ad8b94688a
| Author | SHA1 | Date | |
|---|---|---|---|
| ad8b94688a | |||
| 8171d50efa | |||
| 7e22b92bc9 | |||
| 66973cf80b | |||
| e82afb5530 | |||
| fda51e6e52 |
@@ -34,6 +34,7 @@
|
|||||||
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다.
|
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다.
|
||||||
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
|
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
|
||||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
||||||
|
- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
|
||||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
||||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
||||||
@@ -608,9 +609,32 @@ interface AudioRecommendationQueryPort {
|
|||||||
- `git diff --check`: 출력 없음.
|
- `git diff --check`: 출력 없음.
|
||||||
- `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인.
|
- `./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: 회귀 검증과 문서 기록
|
### Phase 8: 회귀 검증과 문서 기록
|
||||||
|
|
||||||
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
- [x] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
||||||
- Files:
|
- Files:
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||||
@@ -623,8 +647,14 @@ interface AudioRecommendationQueryPort {
|
|||||||
- Run: `./gradlew ktlintCheck`
|
- Run: `./gradlew ktlintCheck`
|
||||||
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
||||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 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:
|
- Files:
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||||
@@ -638,6 +668,12 @@ interface AudioRecommendationQueryPort {
|
|||||||
- Run: `./gradlew tasks --all`
|
- Run: `./gradlew tasks --all`
|
||||||
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
|
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
|
||||||
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
|
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 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-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 후속 정책 변경: 콘텐츠 추천 탭의 `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 후속 정책 변경: 콘텐츠 추천 탭의 `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`로 통과했다.
|
||||||
|
|||||||
@@ -146,6 +146,7 @@
|
|||||||
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
||||||
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
||||||
- 최대 5개를 표시한다.
|
- 최대 5개를 표시한다.
|
||||||
|
- 스냅샷 저장 후보에 같은 크리에이터의 오디오가 여러 개 있으면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
|
||||||
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
||||||
|
|
||||||
#### Edge Cases
|
#### Edge Cases
|
||||||
@@ -285,6 +286,7 @@ data class CommentedAudioResponse(
|
|||||||
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
||||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
||||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
||||||
|
- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
||||||
- 섹션별 기본 노출 수:
|
- 섹션별 기본 노출 수:
|
||||||
- `followingCreators`: 최신 팔로우순 20개
|
- `followingCreators`: 오래된 팔로우순 20개
|
||||||
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
|
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
|
||||||
- `recentChats`: DM/AI 채팅 최신순 10개
|
- `recentChats`: DM/AI 채팅 최신순 10개
|
||||||
- `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개
|
- `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/HomeFollowingQueryRepository.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.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`
|
- 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: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
|
- RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
|
||||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
|
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
|
||||||
- GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다.
|
- 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`.
|
- 리뷰 보완 후 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`.
|
- 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
||||||
- 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다.
|
- 응답 단순화: `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`.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||||
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
|
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
|
||||||
- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다.
|
- 사용자가 팔로우한 크리에이터 목록을 오래된 팔로우순 20개 응답한다.
|
||||||
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
|
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
|
||||||
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
|
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
|
||||||
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
|
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. User Stories
|
## 6. User Stories
|
||||||
- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다.
|
- 사용자는 내가 팔로우한 크리에이터 목록을 오래 팔로우한 순서로 보고 싶다.
|
||||||
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
|
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
|
||||||
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
|
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
|
||||||
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
|
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
### Feature B. 팔로잉 크리에이터
|
### Feature B. 팔로잉 크리에이터
|
||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다.
|
- 사용자가 팔로우한 활성 크리에이터를 오래된 팔로우순으로 최대 20개 조회한다.
|
||||||
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
|
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
|
||||||
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
|
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
|
||||||
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
|
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
|
||||||
|
|||||||
13
docs/20260712_DM룸_푸시문구_수정/plan-task.md
Normal file
13
docs/20260712_DM룸_푸시문구_수정/plan-task.md
Normal file
@@ -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를 실행한다.
|
||||||
20
docs/20260712_DM룸_푸시문구_수정/prd.md
Normal file
20
docs/20260712_DM룸_푸시문구_수정/prd.md
Normal file
@@ -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`에서 조회된다.
|
||||||
@@ -1170,6 +1170,11 @@ class SodaMessageSource {
|
|||||||
Lang.EN to "You have received a text message from %s.",
|
Lang.EN to "You have received a text message from %s.",
|
||||||
Lang.JA to "%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(
|
"message.fcm.voice_received" to mapOf(
|
||||||
Lang.KO to "%s님으로 부터 음성메시지가 도착했습니다.",
|
Lang.KO to "%s님으로 부터 음성메시지가 도착했습니다.",
|
||||||
Lang.EN to "You have received a voice message from %s.",
|
Lang.EN to "You have received a voice message from %s.",
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
else 0.0
|
else 0.0
|
||||||
end * 20.0
|
end * 20.0
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
requireComments = true
|
requireComments = true,
|
||||||
|
dedupeByCreator = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,12 +326,13 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
scoreExpression: String,
|
scoreExpression: String,
|
||||||
requireComments: Boolean = false
|
requireComments: Boolean = false,
|
||||||
|
dedupeByCreator: Boolean = false
|
||||||
): List<RecommendationSnapshotRecord> {
|
): List<RecommendationSnapshotRecord> {
|
||||||
val commentJoin = if (requireComments) "join" else "left join"
|
val commentJoin = if (requireComments) "join" else "left join"
|
||||||
val commentRequirement = if (requireComments) "and cm.comment_count is not null" else ""
|
val commentRequirement = if (requireComments) "and cm.comment_count is not null" else ""
|
||||||
val sql = """
|
val scoredSql = """
|
||||||
select c.id, ($scoreExpression) score, rand() random_tie_breaker
|
select c.id, c.member_id creator_id, ($scoreExpression) score, rand() random_tie_breaker
|
||||||
from content c
|
from content c
|
||||||
join member creator on creator.id = c.member_id
|
join member creator on creator.id = c.member_id
|
||||||
join content_theme theme on theme.id = c.theme_id
|
join content_theme theme on theme.id = c.theme_id
|
||||||
@@ -372,9 +374,29 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
and theme.is_active = true
|
and theme.is_active = true
|
||||||
and (:includeAdult = true or c.is_adult = false)
|
and (:includeAdult = true or c.is_adult = false)
|
||||||
$commentRequirement
|
$commentRequirement
|
||||||
|
""".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
|
order by score desc, random_tie_breaker asc
|
||||||
limit :limit
|
limit :limit
|
||||||
""".trimIndent()
|
""".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)
|
return entityManager.createNativeQuery(sql)
|
||||||
.setParameter("windowStart", windowStart)
|
.setParameter("windowStart", windowStart)
|
||||||
.setParameter("snapshotAt", snapshotAt)
|
.setParameter("snapshotAt", snapshotAt)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class DefaultHomeFollowingQueryRepository(
|
|||||||
creator.role.eq(MemberRole.CREATOR),
|
creator.role.eq(MemberRole.CREATOR),
|
||||||
notBlockedCreatorCondition(memberId, creator.id)
|
notBlockedCreatorCondition(memberId, creator.id)
|
||||||
)
|
)
|
||||||
.orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc())
|
.orderBy(creatorFollowing.createdAt.asc(), creatorFollowing.id.desc())
|
||||||
.limit(limit.toLong())
|
.limit(limit.toLong())
|
||||||
.fetch()
|
.fetch()
|
||||||
.map { row ->
|
.map { row ->
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class UserCreatorChatService(
|
|||||||
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
||||||
"message.fcm.voice_received"
|
"message.fcm.voice_received"
|
||||||
} else {
|
} else {
|
||||||
"message.fcm.text_received"
|
"message.fcm.dm_received"
|
||||||
}
|
}
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
|
|||||||
@@ -21,4 +21,24 @@ class SodaMessageSourceTest {
|
|||||||
sodaMessageSource.getMessage("partner.agent.ratio.not_found", Lang.JA)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
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
|
@Test
|
||||||
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
|
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
|
||||||
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
|
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test")
|
private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 최신 팔로우순으로 조회한다")
|
@DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 오래된 팔로우순으로 조회한다")
|
||||||
fun shouldFindActiveFollowingCreatorsByLatestFollowOrder() {
|
fun shouldFindActiveFollowingCreatorsByOldestFollowOrder() {
|
||||||
val viewer = saveMember("following-viewer", MemberRole.USER)
|
val viewer = saveMember("following-viewer", MemberRole.USER)
|
||||||
val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png")
|
val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png")
|
||||||
val inactiveCreator = saveMember("following-inactive", MemberRole.CREATOR, isActive = false)
|
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)
|
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
|
||||||
|
|
||||||
assertEquals(listOf(activeCreator.id!!, olderCreator.id!!), creators.map { it.creatorId })
|
assertEquals(listOf(olderCreator.id!!, activeCreator.id!!), creators.map { it.creatorId })
|
||||||
assertEquals("https://cdn.test/active.png", creators.first().creatorProfileImageUrl)
|
assertEquals("https://cdn.test/active.png", creators.last().creatorProfileImageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ class UserCreatorChatServiceTest {
|
|||||||
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||||
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||||
|
assertEquals("message.fcm.dm_received", eventCaptor.value.messageKey)
|
||||||
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||||
assertEquals(null, eventCaptor.value.roomId)
|
assertEquals(null, eventCaptor.value.roomId)
|
||||||
assertEquals(null, eventCaptor.value.messageId)
|
assertEquals(null, eventCaptor.value.messageId)
|
||||||
@@ -229,6 +230,7 @@ class UserCreatorChatServiceTest {
|
|||||||
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||||
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||||
|
assertEquals("message.fcm.dm_received", eventCaptor.value.messageKey)
|
||||||
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
Mockito.verifyNoInteractions(roomMessageBroker)
|
||||||
}
|
}
|
||||||
@@ -286,6 +288,7 @@ class UserCreatorChatServiceTest {
|
|||||||
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
|
||||||
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
|
||||||
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
|
||||||
|
assertEquals("message.fcm.voice_received", eventCaptor.value.messageKey)
|
||||||
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
assertEquals(listOf(2L), eventCaptor.value.recipients)
|
||||||
assertEquals(null, eventCaptor.value.roomId)
|
assertEquals(null, eventCaptor.value.roomId)
|
||||||
assertEquals(null, eventCaptor.value.messageId)
|
assertEquals(null, eventCaptor.value.messageId)
|
||||||
|
|||||||
Reference in New Issue
Block a user