Merge pull request 'test' (#447) from test into main
Reviewed-on: #447
This commit was merged in pull request #447.
This commit is contained in:
@@ -0,0 +1,518 @@
|
|||||||
|
# 추천 탭 배너 접속 국가별 언어 필터 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-19 |
|
||||||
|
| 요구사항 기준 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` |
|
||||||
|
| API 기준 | 기존 공개 API 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 2: 언어 필터 limit 선행 회귀 테스트 보완 완료 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
메인 홈 추천과 메인 콘텐츠 추천에서 로그인 여부와 기존 강제 국가 매핑을 반영해 일본은 일본어, 그 외 국가는 한국어 배너만 조회한다.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
기존 `MemberContentPreferenceService.resolveCountryCode`가 비로그인 사용자까지 처리하도록 입력만 확장하고 강제 국가 매핑과 국가 정규화를 재사용한다. 두 추천 application 진입점에서 국가를 `Lang.JA` 또는 `Lang.KO`로 변환해 기존 port에 전달하며, 두 QueryDSL Repository가 `content_banner.lang`을 정렬·limit 전 조회 조건으로 적용한다.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, Gradle Wrapper
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `JP`는 `Lang.JA`, 그 외 국가와 국가 정보 누락은 `Lang.KO`로 고정한다.
|
||||||
|
- 로그인 회원의 기존 강제 KR/JP 매핑은 요청 헤더보다 우선한다.
|
||||||
|
- `Accept-Language`, DB 스키마, 공개 API endpoint/DTO/응답 스키마를 변경하지 않는다.
|
||||||
|
- 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 조건을 유지한다.
|
||||||
|
- 신규 dependency, 언어 resolver abstraction과 메모리 후처리를 추가하지 않는다.
|
||||||
|
- 모든 구현 Task는 RED → GREEN → REFACTOR 순서로 진행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `3/3` | 없음 | 없음 |
|
||||||
|
| 2 | 완료 | `1/1` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- 동시에 하나의 미완료 goal만 운용한다.
|
||||||
|
- 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다.
|
||||||
|
- 실제 검증 결과는 기존 기록을 덮어쓰지 않고 `Progress`에 누적한다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 로그인·비로그인 사용자 공통 국가 코드 판정
|
||||||
|
- 홈 추천 `tab_id IS NULL` 배너의 `KO`/`JA` 필터
|
||||||
|
- 콘텐츠 추천 `tab_id = 2` 배너의 `KO`/`JA` 필터
|
||||||
|
- application → port → persistence 언어 전달
|
||||||
|
- focused application/Repository 테스트와 직접 영향 controller/E2E 회귀
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- `Lang.EN`을 선택하는 국가 정책
|
||||||
|
- 배너 외 추천 섹션의 국가·언어 필터
|
||||||
|
- 관리자/v1 배너 조회 변경
|
||||||
|
- DB 데이터·스키마, API 계약과 dependency 변경
|
||||||
|
- 기존 배너 정책의 리팩터링
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
| 책임 | 파일 |
|
||||||
|
|---|---|
|
||||||
|
| 공통 국가 판정 | `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`, `MemberContentPreferenceCountryResolver.kt` |
|
||||||
|
| 홈 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
||||||
|
| 홈 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`, `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
||||||
|
| 콘텐츠 추천 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` |
|
||||||
|
| 콘텐츠 추천 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`, `v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` |
|
||||||
|
|
||||||
|
## Phase 1: 접속 국가별 배너 언어 필터
|
||||||
|
|
||||||
|
**Phase 결과:** 두 추천 API가 판정 국가에 대응하는 한 언어의 배너만 기존 정책대로 반환한다.
|
||||||
|
|
||||||
|
**선행조건:** 승인된 PRD `BANNER-LANG-001~006`.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 실제 검증 결과 누적.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 로그인·비로그인 공통 국가 판정 (`P1-T1`)
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 기존 강제 국가 매핑을 유지하면서 비로그인 사용자도 동일한 정규화와 기본값으로 국가 코드를 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** PRD `BANNER-LANG-003`, `BANNER-LANG-004` 확정.
|
||||||
|
- **완료 증거:** nullable 회원 국가 판정 테스트가 RED 후 GREEN이고 기존 강제 KR/JP 매핑 테스트가 통과한다.
|
||||||
|
- **범위 밖:** 국가를 배너 언어로 변환하거나 배너를 조회하는 로직.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `CountryContext.countryCode`, 기존 강제 KR/JP 회원 ID 매핑.
|
||||||
|
- Produces: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String` — 정규화한 국가 코드이며 null·blank는 `KR`.
|
||||||
|
|
||||||
|
- [x] **RED:** 비로그인 `JP`, 소문자/공백 포함 JP, 헤더 누락을 검증하는 테스트를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
countryContext.setCountryCode(" jp ")
|
||||||
|
assertEquals("JP", service.resolveCountryCode(null))
|
||||||
|
|
||||||
|
countryContext.setCountryCode(null)
|
||||||
|
assertEquals("KR", service.resolveCountryCode(null))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **RED 확인:** 아래 focused test를 실행해 현재 `Member` non-null 계약 때문에 컴파일이 실패하는지 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** `resolveCountryCode(null)` 호출의 타입 불일치로 실패한다.
|
||||||
|
|
||||||
|
- [x] **GREEN:** 기존 helper가 nullable 회원을 받고, non-null 회원만 기존 ID 필수 조건을 검사하도록 최소 변경한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun resolveCountryCode(member: Member?): String {
|
||||||
|
if (member != null) requireMemberId(member)
|
||||||
|
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
|
||||||
|
val memberId = member?.id
|
||||||
|
if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) {
|
||||||
|
return "KR"
|
||||||
|
}
|
||||||
|
if (memberId != null && FORCED_JP_MEMBER_IDS.contains(memberId)) {
|
||||||
|
return "JP"
|
||||||
|
}
|
||||||
|
return requestCountryCode
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.uppercase()
|
||||||
|
?: "KR"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 비로그인과 기존 강제 매핑 테스트가 모두 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경에서 생긴 중복만 정리하고 공개 국가 정책 메서드를 추가하지 않는다. focused test를 재실행해 결과를 `Progress`에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.2 홈 추천 배너 언어 필터 (`P1-T2`)
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 홈 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-001`, `BANNER-LANG-005` 확정.
|
||||||
|
- **완료 증거:** facade·query service 전달 테스트와 홈 Repository 언어 필터 테스트가 RED 후 GREEN이다.
|
||||||
|
- **범위 밖:** 콘텐츠 추천 탭 배너와 홈의 다른 추천 섹션.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`.
|
||||||
|
- Produces: `findHomeBanners(..., lang: Lang = Lang.KO)` application/port 계약과 QueryDSL `audioContentBanner.lang.eq(lang)` 조건.
|
||||||
|
|
||||||
|
- [x] **RED:** facade가 `JP`를 `Lang.JA`, 그 외를 `Lang.KO`로 전달하고 query service가 `Lang`을 port에 위임하는 테스트를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||||
|
|
||||||
|
facade.getHomeRecommendations(member)
|
||||||
|
|
||||||
|
assertEquals(Lang.JA, queryPort.bannerLang)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **RED:** 홈 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 같은 조건의 KO/JA 배너 중 요청 언어만 반환하는 테스트를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val koBanner = saveBanner("ko.png", AudioContentBannerType.LINK, 1, isActive = true, lang = Lang.KO)
|
||||||
|
val jaBanner = saveBanner("ja.png", AudioContentBannerType.LINK, 2, isActive = true, lang = Lang.JA)
|
||||||
|
|
||||||
|
assertEquals(listOf(koBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.KO).map { it.thumbnailImage })
|
||||||
|
assertEquals(listOf(jaBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.JA).map { it.thumbnailImage })
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 전달 계약과 Repository 조건이 없어 발생하는 컴파일/assertion 실패를 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** facade에서 국가를 언어로 변환해 전달한다. service와 port 시그니처 끝에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
|
|
||||||
|
queryService.findHomeBanners(
|
||||||
|
limit = HOME_BANNER_LIMIT,
|
||||||
|
memberId = member?.id,
|
||||||
|
includeAdultBanners = includeAdult,
|
||||||
|
lang = bannerLang
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
.where(
|
||||||
|
audioContentBanner.isActive.isTrue,
|
||||||
|
audioContentBanner.tab.isNull,
|
||||||
|
audioContentBanner.lang.eq(lang),
|
||||||
|
includeAdultBannerCondition(includeAdultBanners),
|
||||||
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 전달·필터와 기존 홈 배너 조건이 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 국가→언어 변환을 별도 abstraction으로 만들지 않고 한 줄 정책으로 유지한다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다.
|
||||||
|
|
||||||
|
#### Task 1.3 콘텐츠 추천 배너 언어 필터 (`P1-T3`)
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 콘텐츠 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-002`, `BANNER-LANG-005` 확정.
|
||||||
|
- **완료 증거:** query service 전달 테스트와 콘텐츠 추천 Repository 언어 필터 테스트가 RED 후 GREEN이다.
|
||||||
|
- **범위 밖:** 홈 추천 배너와 오디오 추천의 배너 외 섹션.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.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/AudioRecommendationQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`.
|
||||||
|
- Produces: `AudioRecommendationQueryPort.findBanners(..., lang: Lang = Lang.KO)`와 QueryDSL `audioContentBanner.lang.eq(lang)` 조건.
|
||||||
|
|
||||||
|
- [x] **RED:** 기존 `shouldUseStoredPreferenceForMemberAdultVisibility`에 JP 국가 stub과 배너 호출 검증을 추가하고, 비로그인 조립 테스트는 `Lang.KO` 전달을 검증하도록 보강한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||||
|
|
||||||
|
service.getRecommendations(member)
|
||||||
|
|
||||||
|
Mockito.verify(queryPort).findBanners(
|
||||||
|
AudioRecommendationQueryService.BANNER_LIMIT,
|
||||||
|
member.id,
|
||||||
|
true,
|
||||||
|
Lang.JA
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **RED:** 콘텐츠 추천 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 동일 탭의 KO/JA 배너가 요청 언어별로 분리되는 테스트를 추가한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val koBanner = saveBanner(
|
||||||
|
"ko.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
1,
|
||||||
|
link = "https://ko.test",
|
||||||
|
tab = recommendationTab,
|
||||||
|
lang = Lang.KO
|
||||||
|
)
|
||||||
|
val jaBanner = saveBanner(
|
||||||
|
"ja.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
2,
|
||||||
|
link = "https://ja.test",
|
||||||
|
tab = recommendationTab,
|
||||||
|
lang = Lang.JA
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("https://cdn.test/${koBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.KO).map { it.imageUrl })
|
||||||
|
assertEquals(listOf("https://cdn.test/${jaBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.JA).map { it.imageUrl })
|
||||||
|
```
|
||||||
|
- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 인자와 Repository 필터가 없어 발생하는 컴파일/assertion 실패를 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** `getRecommendations`에서 국가를 언어로 변환해 배너 호출에만 전달한다. port에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
|
|
||||||
|
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang)
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
.where(
|
||||||
|
audioContentBanner.isActive.isTrue,
|
||||||
|
audioContentBanner.tab.id.eq(2L),
|
||||||
|
audioContentBanner.lang.eq(lang),
|
||||||
|
adultBannerCondition(canViewAdultContent),
|
||||||
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 필터와 기존 탭·성인·차단 조건이 함께 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 배너 외 오디오 추천 로직을 변경하지 않는다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`~`P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `BANNER-LANG-001~006`이 구현 또는 명시적 제외로 추적된다.
|
||||||
|
- [x] 공개 API와 DB 스키마 변경이 없다.
|
||||||
|
- [x] 실제 검증 결과가 `Progress`에 기록됐다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 두 추천 API의 국가별 배너 언어 필터와 기존 배너 정책의 비회귀를 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`~`P1-T3` 완료.
|
||||||
|
- **완료 증거:** 아래 focused/영향 범위 test, lint, 문서 명령과 수동 검증 통과 및 `Progress` 기록.
|
||||||
|
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
./gradlew tasks --all
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** 모든 명령이 exit code `0`으로 끝난다. JP 판정은 두 API에서 JA 배너만, 그 외 판정은 KO 배너만 반환하며 기존 탭·성인·활성·차단·대상 유효성·정렬·limit와 응답 스키마가 유지된다.
|
||||||
|
|
||||||
|
수동 검증:
|
||||||
|
|
||||||
|
- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다.
|
||||||
|
- [x] `git diff`에서 endpoint, request/response DTO, DB 스키마, dependency와 `Accept-Language` 사용이 추가되지 않았는지 확인한다.
|
||||||
|
- [x] 두 Repository 모두 `lang` 조건이 `orderBy`와 `limit`보다 앞선 `where`에 있는지 확인한다.
|
||||||
|
|
||||||
|
전체 회귀 `./gradlew test`는 변경이 국가 판정과 두 배너 조회 경로에 한정되고 위 명령이 application, persistence, controller/E2E를 포함하므로 기본 생략한다. focused test로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 국가 판정 변경의 회귀 범위가 확대되면 전체 회귀를 실행하고 결과를 `Progress`에 기록한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 승인된 PRD | 아니요 | 기존 국가 정규화·강제 매핑 테스트와 구현을 다시 대조 |
|
||||||
|
| 2 | `P1-T2` | `P1-T1` | 아니요 | 홈 application/port/repository 계약을 한 단계씩 대조 |
|
||||||
|
| 3 | `P1-T3` | `P1-T1` | 아니요 | 콘텐츠 추천 application/port/repository 계약을 한 단계씩 대조 |
|
||||||
|
| 4 | `P1-GATE` | `P1-T1`~`P1-T3` | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
P1-T1 → P1-T2 → P1-T3 → P1-GATE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||||
|
- 공개 endpoint, request/response DTO와 API envelope를 변경하지 않는다.
|
||||||
|
- DB schema, 배너 데이터, 관리자/v1 배너 API를 변경하지 않는다.
|
||||||
|
- `Accept-Language`나 회원 저장 언어를 국가 판정 대신 사용하지 않는다.
|
||||||
|
- 조회 후 컬렉션 filter로 언어를 제거하지 않는다.
|
||||||
|
- 신규 dependency, enum, 공통 abstraction과 관련 없는 리팩터링을 추가하지 않는다.
|
||||||
|
- test를 삭제·skip·완화하지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- PRD와 구현이 충돌하면 `prd.md`의 Decision Log와 이 계획을 먼저 갱신한 뒤 구현한다.
|
||||||
|
- `CloudFront-Viewer-Country` 또는 기존 강제 국가 매핑 정책을 바꿔야 하면 범위 확장으로 보고 사용자 승인 전 중단한다.
|
||||||
|
- `Lang.EN`, 다른 국가별 언어 또는 배너 외 추천 섹션이 필요해지면 별도 요구사항으로 분리한다.
|
||||||
|
- 공개 API, DB schema 또는 관리자/v1 조회 변경이 필요해지면 사용자 승인 전 진행하지 않는다.
|
||||||
|
- 체크박스, test와 `Progress` 기록이 모두 충족된 뒤에만 Goal을 완료 처리한다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### 2026-08-19 문서 작성
|
||||||
|
|
||||||
|
- 사용자 인터뷰로 `JP → JA`, 그 외 국가 → `KO` 정책과 로그인 회원의 기존 강제 국가 매핑 적용을 확정했다.
|
||||||
|
- PRD와 구현 계획만 작성했으며 production/test 코드는 변경하지 않았다.
|
||||||
|
- placeholder 검색과 `git diff --check`에서 문제가 없음을 확인했다.
|
||||||
|
- `./gradlew tasks --all`은 최초 sandbox의 Gradle wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2026-08-19 P1-T1 완료
|
||||||
|
|
||||||
|
- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` 실행 결과 `resolveCountryCode(null)` 4곳이 `Member` non-null 타입 불일치로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||||
|
- GREEN/REFACTOR: `MemberContentPreferenceService.resolveCountryCode`와 `resolveCountryCodeWithForcedMapping`만 nullable 회원을 받도록 최소 변경했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2026-08-19 P1-T2 완료
|
||||||
|
|
||||||
|
- RED: 홈 facade/service/repository 테스트에 `Lang` 전달과 `content_banner.lang` 조건 기대를 추가한 뒤 focused test를 실행했고, `findHomeBanners`의 `lang` 파라미터 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||||
|
- GREEN/REFACTOR: 홈 facade에서 `JP`만 `Lang.JA`, 그 외 `Lang.KO`를 한 줄로 선택해 service/port/repository에 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2026-08-19 P1-T3 완료
|
||||||
|
|
||||||
|
- RED: 콘텐츠 추천 service/repository 테스트에 `Lang.KO` 기본 전달, `JP`의 `Lang.JA` 전달, 추천 탭 배너 언어 분리 기대를 추가한 뒤 focused test를 실행했고, `findBanners`의 `Lang` 인자 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||||
|
- GREEN/REFACTOR: `AudioRecommendationQueryService.getRecommendations`에서 배너 호출에만 `JP → Lang.JA`, 그 외 `Lang.KO`를 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2026-08-19 P1-GATE 완료
|
||||||
|
|
||||||
|
- Phase Gate focused/controller/E2E 명령 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `./gradlew ktlintCheck`는 최초 실행에서 테스트 import 순서 2곳으로 실패했고, import 정렬 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `git diff --check` 실행 결과 출력 없이 통과했다.
|
||||||
|
- `git diff --name-only`와 diff 검토로 계획된 production/test 파일 및 이 작업 문서 외 변경이 없고, 공개 endpoint/request/response DTO, DB 스키마, dependency, `Accept-Language` 정책이 추가되지 않았음을 확인했다.
|
||||||
|
- 두 Repository 모두 `audioContentBanner.lang.eq(lang)` 조건이 `where` 안에서 `orderBy`와 `limit`보다 먼저 적용됨을 확인했다.
|
||||||
|
- Reviewer Gate에서 요구사항 차단 이슈 없음으로 승인받았다.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-19 | `DEC-001` | 확정 | 공통 국가 판정을 먼저 nullable 회원까지 확장한 뒤 두 배너 경로가 소비한다. | 기존 강제 매핑·정규화 재사용과 중복 방지 | `P1-T1`~`P1-T3` |
|
||||||
|
| 2026-08-19 | `DEC-002` | 확정 | 두 port의 `lang`은 기존 직접 호출 호환을 위해 기본값 `Lang.KO`를 사용한다. | 비JP 정책과 기존 KO fixture 일치 | `P1-T2`, `P1-T3` |
|
||||||
|
| 2026-08-19 | `DEC-003` | 확정 | 국가→언어 변환은 두 application 진입점의 한 줄 정책으로 두고 별도 abstraction을 만들지 않는다. | 사용처가 두 곳뿐인 고정 정책의 최소 구현 | `P1-T2`, `P1-T3` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
### 2026-08-19 2차 리뷰
|
||||||
|
|
||||||
|
- production 호출 흐름과 QueryDSL 조건에는 요구사항 차단 문제가 없다.
|
||||||
|
- 두 Repository 언어 테스트가 `limit = 20`으로 배너 2개를 모두 수용해 `BANNER-LANG-005`의 "다른 언어 배너가 limit을 차지하지 않음" 회귀를 직접 방어하지 못하는 테스트 공백을 확인했다.
|
||||||
|
|
||||||
|
## Phase 2: 언어 필터 limit 선행 회귀 테스트 보완
|
||||||
|
|
||||||
|
**Phase 결과:** 두 Repository 테스트가 언어 조건이 제거되거나 limit 이후로 밀리는 회귀를 직접 검출한다.
|
||||||
|
|
||||||
|
**선행조건:** `P1-GATE` 완료와 2차 리뷰 테스트 공백 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, mutation RED와 복구 후 GREEN 결과 누적.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 2.1 언어 필터가 limit보다 먼저 적용되는 회귀 테스트 (`P2-T1`)
|
||||||
|
|
||||||
|
**Goal 실행 `P2-T1`:** 낮은 `orders`의 반대 언어 배너가 있어도 `limit = 1` 조회는 요청 언어 배너를 반환함을 두 Repository에서 검증한다.
|
||||||
|
|
||||||
|
- **시작 조건:** PRD `BANNER-LANG-005`, 2차 리뷰 결과 확정.
|
||||||
|
- **완료 증거:** 두 테스트가 정상 구현에서 통과하고, 각 Repository의 언어 조건 제거 mutation에서 의도한 assertion 실패를 보인 뒤 복구 후 다시 통과한다.
|
||||||
|
- **범위 밖:** production query, 배너 정렬 정책, API 계약 변경.
|
||||||
|
- **TDD 예외 사유:** production 동작은 이미 올바르고 이번 보완은 기존 테스트가 특정 회귀를 검출하는지 확인하는 테스트 전용 변경이다.
|
||||||
|
- **대체 검증 방법:** 테스트를 먼저 보강한 뒤 각 Repository의 `audioContentBanner.lang.eq(lang)`을 일시 제거해 실패를 확인하고 즉시 복구한 뒤 같은 테스트의 통과를 확인한다.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||||
|
- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
|
- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||||
|
|
||||||
|
- [x] **RED:** 각 언어 테스트에서 `orders = 1`인 반대 언어와 `orders = 2`인 요청 언어를 만들고 `limit = 1` 결과가 요청 언어 한 건인지 검증한다.
|
||||||
|
- [x] **RED 확인:** 홈 Repository의 언어 조건을 일시 제거하고 홈 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다.
|
||||||
|
- [x] **RED 확인:** 콘텐츠 추천 Repository의 언어 조건을 일시 제거하고 콘텐츠 추천 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다.
|
||||||
|
- [x] **GREEN:** production 변경 없이 두 테스트의 최종 fixture와 `limit = 1` assertion만 유지한다.
|
||||||
|
- [x] **GREEN 확인:** 아래 두 focused test class가 통과하는지 확인한다.
|
||||||
|
- [x] **REFACTOR:** 테스트 이름·fixture 중복만 최소 정리하고 production diff가 Phase 1 구현과 동일한지 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2 Gate (`P2-GATE`)
|
||||||
|
|
||||||
|
- **시작 조건:** `P2-T1` 완료.
|
||||||
|
- **완료 증거:** Phase 1 Gate focused/controller/E2E 테스트, `ktlintCheck`, `tasks --all`, `git diff --check` 통과와 재리뷰 결과 기록.
|
||||||
|
- **범위 밖:** 새 production 동작과 관련 없는 테스트 확장.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
./gradlew tasks --all
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** 모든 명령이 exit code `0`으로 끝나고, 두 언어 테스트는 반대 언어가 더 낮은 `orders`여도 요청 언어 배너를 한 건 반환한다.
|
||||||
|
|
||||||
|
## Phase 2 Progress
|
||||||
|
|
||||||
|
### 2026-08-19 `P2-T1` 완료
|
||||||
|
|
||||||
|
- 테스트 보완: 두 Repository 언어 테스트를 `limit = 1`로 강화하고 이름을 limit 선행 동작이 드러나도록 변경했다.
|
||||||
|
- 홈 mutation RED: `DefaultHomeRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultHomeRecommendationQueryRepositoryTest.kt:276`에서 assertion 실패함을 확인하고 즉시 복구했다.
|
||||||
|
- 콘텐츠 추천 mutation RED: `DefaultAudioRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultAudioRecommendationQueryRepositoryTest.kt:126`에서 assertion 실패함을 확인하고 즉시 복구했다.
|
||||||
|
- GREEN: 복구 후 두 Repository focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
|
||||||
|
### 2026-08-19 `P2-GATE` 완료
|
||||||
|
|
||||||
|
- Phase 1 Gate와 같은 focused/application/Repository/controller/E2E 테스트 9개 class를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||||
|
- `./gradlew ktlintCheck`와 `./gradlew tasks --all`이 각각 `BUILD SUCCESSFUL`로 끝났다.
|
||||||
|
- `git diff --check` 결과 오류가 없고, 두 production Repository에 `audioContentBanner.lang.eq(lang)`이 복구된 상태임을 확인했다.
|
||||||
|
- 최종 독립 재리뷰에서 최초 Important 이슈 해소와 Critical/Important/Minor 추가 이슈 없음 판정을 받았다.
|
||||||
|
- 전체 회귀 `./gradlew test`는 실행하지 않았다. 변경이 두 기존 Repository 테스트의 limit 경계 강화에 한정되고 Phase Gate가 직접 영향 application/persistence/controller/E2E 범위를 포함하므로 생략했다.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 추천 탭 배너 접속 국가별 언어 필터 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 기준 확정 |
|
||||||
|
| 작성일 | 2026-08-19 |
|
||||||
|
| 최종 수정일 | 2026-08-19 |
|
||||||
|
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
|
||||||
|
| 작성자·결정권자 | 사용자 |
|
||||||
|
| 관련 API Contract | 신규 없음. 기존 공개 API 계약 유지 |
|
||||||
|
| 관련 구현 계획 | `docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md` |
|
||||||
|
| 관련 기존 문서 | `docs/20260805_추천탭_배너_조회조건/prd.md`, `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
메인 홈 추천 API와 메인 콘텐츠 추천 API에서 접속 국가에 맞는 언어의 배너만 반환한다. 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정 결과를 사용하고, 비로그인 사용자는 `CloudFront-Viewer-Country` 요청 헤더를 기준으로 판정한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- 두 추천 API의 현재 배너 QueryDSL 조회에는 `content_banner.lang` 조건이 없다.
|
||||||
|
- 그 결과 한국어·일본어·영어 배너가 같은 응답에 섞일 수 있다.
|
||||||
|
- 언어 필터가 조회 후 적용되면 다른 언어 배너가 limit을 차지해 필요한 배너가 누락될 수 있다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 두 API가 배너 정렬과 limit 적용 전에 확정된 언어 조건을 적용하고, 기존 배너 조회 조건과 공개 응답 스키마를 유지하는 것으로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- 접속 국가 판정 결과가 `JP`이면 `Lang.JA` 배너만 반환한다.
|
||||||
|
- `JP` 이외의 국가와 국가 정보가 없는 경우에는 `Lang.KO` 배너만 반환한다.
|
||||||
|
- 로그인 회원은 `MemberContentPreferenceService.resolveCountryCode`의 기존 강제 국가 매핑을 유지한다.
|
||||||
|
- 비로그인 사용자도 요청 국가를 판정할 수 있도록 동일한 국가 정규화와 기본값 정책을 사용한다.
|
||||||
|
- 언어 조건을 DB 조회 단계에서 정렬·limit보다 먼저 적용한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- `Lang.EN` 배너를 반환하는 국가 정책을 추가하지 않는다.
|
||||||
|
- `Accept-Language`를 배너 언어 판정에 사용하지 않는다.
|
||||||
|
- `CloudFront-Viewer-Country` 헤더 계약이나 `CountryInterceptor`를 변경하지 않는다.
|
||||||
|
- 공개 API endpoint, request/response DTO와 응답 스키마를 변경하지 않는다.
|
||||||
|
- 기존 배너의 탭·성인·활성·차단·대상 유효성·정렬·limit 정책을 변경하지 않는다.
|
||||||
|
- DB 스키마, 배너 데이터, 관리자 배너 API와 기존 v1 배너 조회를 변경하지 않는다.
|
||||||
|
- 배너 이외의 추천 섹션에 국가 또는 언어 필터를 추가하지 않는다.
|
||||||
|
|
||||||
|
## 5. 대상 사용자와 국가 판정
|
||||||
|
|
||||||
|
| 사용자 | 국가 판정 | 언어 선택 |
|
||||||
|
|---|---|---|
|
||||||
|
| 강제 JP 매핑 로그인 회원 | 기존 강제 매핑 결과 `JP` | `Lang.JA` |
|
||||||
|
| 강제 KR 매핑 로그인 회원 | 기존 강제 매핑 결과 `KR` | `Lang.KO` |
|
||||||
|
| 일반 로그인 회원 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` |
|
||||||
|
| 비로그인 사용자 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` |
|
||||||
|
|
||||||
|
국가 코드는 기존 정책처럼 앞뒤 공백을 제거하고 대문자로 정규화한다.
|
||||||
|
|
||||||
|
## 6. 핵심 조회 흐름
|
||||||
|
|
||||||
|
1. `CountryInterceptor`가 `CloudFront-Viewer-Country`를 `CountryContext`에 저장한다.
|
||||||
|
2. `HomeRecommendationFacade.getHomeRecommendations`와 `AudioRecommendationQueryService.getRecommendations`가 회원과 요청 국가로 국가 코드를 판정한다.
|
||||||
|
3. 국가 코드가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
|
||||||
|
4. 선택한 `Lang`을 기존 application → port → persistence 경로로 전달한다.
|
||||||
|
5. Repository가 `content_banner.lang = :lang`을 기존 조건과 함께 적용한 뒤 정렬하고 limit을 적용한다.
|
||||||
|
|
||||||
|
## 7. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `BANNER-LANG-001` | 확정 | `GET /api/v2/home/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 홈 배너만 반환한다. | `tab_id IS NULL`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T2` |
|
||||||
|
| `BANNER-LANG-002` | 확정 | `GET /api/v2/audio/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 콘텐츠 추천 배너만 반환한다. | `tab_id = 2`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T3` |
|
||||||
|
| `BANNER-LANG-003` | 확정 | 로그인 회원의 국가 판정은 기존 강제 KR/JP 매핑을 요청 헤더보다 우선한다. | 강제 JP 회원은 비JP 헤더에서도 `JP`, 강제 KR 회원은 JP 헤더에서도 `KR`로 판정된다. | `P1-T1` |
|
||||||
|
| `BANNER-LANG-004` | 확정 | 비로그인 사용자는 요청 국가를 정규화해 사용하고, 헤더가 없거나 비어 있으면 `KR`로 판정한다. | `JP`·`jp`·공백 포함 JP는 `JP`, null·blank는 `KR`로 판정된다. | `P1-T1` |
|
||||||
|
| `BANNER-LANG-005` | 확정 | 언어 필터는 정렬·limit 전에 DB 조회 조건으로 적용한다. | 다른 언어 배너가 limit을 차지하지 않고, Repository 테스트가 언어별 결과를 검증한다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `BANNER-LANG-006` | 확정 | 기존 배너 조회 정책과 공개 API 계약을 유지한다. | 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 테스트와 controller/E2E 회귀가 통과한다. | `P1-GATE` |
|
||||||
|
|
||||||
|
## 8. API 계약
|
||||||
|
|
||||||
|
| Method | Path | 변경 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. |
|
||||||
|
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. |
|
||||||
|
|
||||||
|
- 신규 request header는 추가하지 않는다.
|
||||||
|
- 기존 `CloudFront-Viewer-Country` 처리 경로를 재사용한다.
|
||||||
|
- 배너가 없으면 기존처럼 빈 목록을 반환한다.
|
||||||
|
|
||||||
|
## 9. 성능과 품질 요구사항
|
||||||
|
|
||||||
|
- `content_banner.lang` 조건은 QueryDSL `where`에 포함하고 메모리 후처리를 사용하지 않는다.
|
||||||
|
- 신규 dependency, 캐시, 공통 resolver abstraction을 추가하지 않는다.
|
||||||
|
- 기존 `Lang`, `CountryContext`, `MemberContentPreferenceService`를 재사용한다.
|
||||||
|
- application 전달 테스트와 두 Repository 조회 테스트를 TDD로 보강한다.
|
||||||
|
- 직접 영향 controller/E2E 회귀와 `ktlintCheck`를 Phase Gate에서 검증한다.
|
||||||
|
|
||||||
|
## 10. 성공 기준
|
||||||
|
|
||||||
|
- [x] 강제 매핑을 포함한 판정 국가가 `JP`이면 두 API의 배너가 모두 `JA`로 제한된다. (`BANNER-LANG-001~003`)
|
||||||
|
- [x] 일반 국가와 국가 정보가 없는 경우 두 API의 배너가 모두 `KO`로 제한된다. (`BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-004`)
|
||||||
|
- [x] 다른 언어 배너가 정렬·limit 대상에 포함되지 않는다. (`BANNER-LANG-005`)
|
||||||
|
- [x] 기존 배너 필터와 공개 응답 계약이 유지된다. (`BANNER-LANG-006`)
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
## 12. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-19 | `DEC-001` | 확정 | `JP`는 `JA`, 그 외 국가는 `KO` 배너만 조회한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-005` |
|
||||||
|
| 2026-08-19 | `DEC-002` | 확정 | 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정을 사용한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-003`, `P1-T1` |
|
||||||
|
| 2026-08-19 | `DEC-003` | 확정 | 비로그인은 요청 국가를 사용하고 국가 정보가 없으면 기존 기본값 `KR`을 사용한다. | 승인된 설계 | `BANNER-LANG-004`, `P1-T1` |
|
||||||
|
| 2026-08-19 | `DEC-004` | 확정 | 언어 필터는 Repository 조회 조건으로 적용한다. | limit 이전 필터링과 기존 QueryDSL 패턴 유지 | `BANNER-LANG-005`, `P1-T2`, `P1-T3` |
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# 홈 팔로잉 최근 대화 팔로우 필터 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. 체크박스는 구현 시 실제 진행 상태에 맞게 갱신한다.
|
||||||
|
|
||||||
|
**Goal:** 홈 팔로잉 탭에서 현재 팔로우 중인 크리에이터의 DM 및 AI 채팅만 최근 대화로 제공한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 `ChatRoomListService`의 병합·정렬·응답 변환을 재사용하고, 홈 팔로잉 호출만 `followedCreatorsOnly = true`를 전달한다. AI/DM 저장소 query는 활성 `CreatorFollowing` 존재 조건을 pagination 전에 적용하며, 기본값 `false`로 일반 채팅 목록 동작을 유지한다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA/JPQL, Gradle, JUnit 5, Mockito, MockMvc
|
||||||
|
|
||||||
|
**Spec:** `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 공개 API request/response schema를 변경하지 않는다.
|
||||||
|
- DM과 AI 채팅을 모두 포함하고 현재 `CreatorFollowing.isActive = true`인 대상만 허용한다.
|
||||||
|
- 팔로우 조건은 DB query에서 pagination보다 먼저 적용한다.
|
||||||
|
- 일반 `GET /api/v2/chat/rooms`는 기존 전체 조회 동작을 유지한다.
|
||||||
|
- DB schema, index, dependency를 추가하지 않는다.
|
||||||
|
- 메시지 본문, 인증 정보와 식별자를 새 log에 기록하지 않는다.
|
||||||
|
- 요청 범위 밖 리팩터링을 하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-19 |
|
||||||
|
| 요구사항 기준 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md` |
|
||||||
|
| API 기준 | 기존 `GET /api/v2/home/following`, `GET /api/v2/chat/rooms` 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 1 완료 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- 동시에 하나의 미완료 goal만 운용한다.
|
||||||
|
- 사용자가 명시적으로 요청하지 않았으므로 goal token budget은 설정하지 않는다.
|
||||||
|
- 이 문서 작성 단계에서는 production/test 코드를 수정하거나 테스트를 실행하지 않는다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- 홈 팔로잉 최근 대화에 활성 팔로우 DM/AI filter 적용
|
||||||
|
- 기존 정렬, cursor 해석, 최대 10개와 응답 변환 재사용
|
||||||
|
- 일반 채팅 목록의 전체 조회 기본값 유지
|
||||||
|
- facade, service, repository query와 홈 팔로잉 E2E 회귀 검증
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- 공개 API schema, 채팅방 생성과 메시지 전송 정책 변경
|
||||||
|
- 팔로우/언팔로우 쓰기 로직과 알림 정책 변경
|
||||||
|
- 차단·계정 활성 상태 등 추가 노출 정책
|
||||||
|
- DB migration, index와 dependency 추가
|
||||||
|
- 홈 팔로잉의 최근 대화 외 섹션 변경
|
||||||
|
|
||||||
|
## 파일 책임과 변경 범위
|
||||||
|
|
||||||
|
| 파일 | 계획된 책임 |
|
||||||
|
|---|---|
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` | 홈 팔로잉 최근 대화 호출에 `followedCreatorsOnly = true` 전달 |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt` | 팔로우 필터 옵션을 AI/DM repository에 전달하고 기존 병합·정렬 유지 |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` | AI 캐릭터 `creatorMember.id` 기준 활성 팔로우 조건 적용 |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` | DM 상대 `member.id` 기준 활성 팔로우 조건 적용 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` | facade가 홈 전용 팔로우 필터를 요청하는지 검증 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt` | filter 옵션 전달과 기존 전체 조회 회귀 검증 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` | 활성 팔로우 DM/AI 포함과 미팔로우·비활성 팔로우 제외 검증 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt` | 수정 없이 일반 채팅 목록 위임 회귀 확인 |
|
||||||
|
|
||||||
|
## Phase 1: 최근 대화 팔로우 필터 적용
|
||||||
|
|
||||||
|
**Phase 결과:** 홈 팔로잉 탭은 활성 팔로우 크리에이터의 DM/AI 최근 대화만 반환하고 일반 채팅 목록은 기존 동작을 유지한다.
|
||||||
|
|
||||||
|
**선행조건:** 승인된 PRD `HFC-001~008`.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 실제 검증 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 홈 팔로잉 최근 대화 필터
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 활성 팔로우 관계를 AI/DM query에 적용하고 홈 팔로잉 facade에서만 해당 필터를 활성화한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `HFC-001~008` 확정, 현재 코드와 공개 API 계약 재확인.
|
||||||
|
- **완료 증거:** 아래 체크박스 전체 완료, focused test 성공, 변경 파일과 실제 결과를 Progress에 기록.
|
||||||
|
- **범위 밖:** 채팅 목록 공개 filter enum 추가, 별도 query 복제, 메모리 후처리, 신규 abstraction·dependency·migration.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 요청 회원 `Member.id`, DM 상대 `opponent.member.id`, AI 대상 `pc.character.creatorMember.id`, `CreatorFollowing.isActive`.
|
||||||
|
- Produces: 기존 반환형 `ChatRoomListPageResponse`; 공개 DTO와 endpoint 계약 변경 없음.
|
||||||
|
- `ChatRoomListService.getRooms`는 아래 parameter만 마지막에 추가하고 기본값으로 기존 호출을 보존한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun getRooms(
|
||||||
|
member: Member,
|
||||||
|
filter: String = ChatRoomListFilter.ALL.name,
|
||||||
|
cursor: String? = null,
|
||||||
|
limit: Int = DEFAULT_LIMIT,
|
||||||
|
followedCreatorsOnly: Boolean = false
|
||||||
|
): ChatRoomListPageResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ChatRoomRepository.findAiChatListRooms`와 `UserCreatorChatRoomRepository.findDmChatListRooms`에는 `@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean`을 추가한다.
|
||||||
|
|
||||||
|
- [x] **RED:** `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember` fixture에 활성 팔로우 DM, 활성 팔로우 AI, 더 최신인 미팔로우 DM 11개, `isActive = false` 팔로우 DM을 만든다. 응답에는 활성 팔로우 AI/DM room 두 개만 기존 최신순으로 남는 assertion을 추가해 저장소 filter가 pagination보다 먼저 적용되는지도 함께 검증한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
.andExpect(jsonPath("$.data.recentChats.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI"))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM"))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **RED 확인:** 아래 명령을 실행해 현재 구현이 더 최신인 미팔로우·비활성 팔로우 DM을 최대 10개 반환하므로 `recentChats.length()` 또는 room 순서 assertion이 실패하는지 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** AI/DM repository query parameter를 service에서 전달하고, query의 기존 방·참가자·메시지 활성 조건 뒤에 아래 활성 팔로우 존재 조건을 추가한다. 조건은 `Pageable` 적용 전에 평가한다.
|
||||||
|
|
||||||
|
AI query:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
AND (
|
||||||
|
:followedCreatorsOnly = false
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM CreatorFollowing cf
|
||||||
|
WHERE cf.member.id = :memberId
|
||||||
|
AND cf.creator.id = pc.character.creatorMember.id
|
||||||
|
AND cf.isActive = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
DM query:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
AND (
|
||||||
|
:followedCreatorsOnly = false
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM CreatorFollowing cf
|
||||||
|
WHERE cf.member.id = :memberId
|
||||||
|
AND cf.creator.id = opponent.member.id
|
||||||
|
AND cf.isActive = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** `HomeFollowingFacade`만 아래처럼 filter를 활성화한다. 일반 `ChatRoomListController` 호출은 기본값 `false`를 사용한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
val recentChats = chatRoomListService.getRooms(
|
||||||
|
member,
|
||||||
|
filter = "ALL",
|
||||||
|
cursor = null,
|
||||||
|
limit = 10,
|
||||||
|
followedCreatorsOnly = true
|
||||||
|
).rooms
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **GREEN:** `ChatRoomListServiceTest`의 repository stub/verify에 `followedCreatorsOnly` 인자를 반영하고, `true`가 AI와 DM repository 양쪽에 전달되는 test를 추가한다. 기존 test는 `false` 전달과 병합·정렬·cursor·preview가 유지되는지 검증한다.
|
||||||
|
- [x] **GREEN:** `HomeFollowingFacadeTest`가 `followedCreatorsOnly = true` 호출을 stub/verify하고 비로그인 시 무호출을 계속 검증하도록 수정한다.
|
||||||
|
- [x] **GREEN 확인:** 아래 focused test를 실행해 facade, service, query와 endpoint 동작을 함께 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** 모든 test가 통과하고 홈 팔로잉 E2E에서 활성 팔로우 AI/DM 두 방만 기존 최신순으로 반환된다.
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 이번 Task에서 생긴 중복만 정리한다. 별도 filter enum, query method 복제와 공통 abstraction은 만들지 않는다. focused test와 아래 직접 영향 회귀·lint를 다시 실행하고 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `HFC-001~008`이 구현 또는 명시적 제외로 추적된다.
|
||||||
|
- [x] 공개 API와 DB schema 변경이 없다.
|
||||||
|
- [x] 실제 검증 결과가 Progress에 기록됐다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 홈 팔로잉 필터와 일반 채팅 목록 비회귀를 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료.
|
||||||
|
- **완료 증거:** 아래 test와 lint 성공, 전체 회귀 실행 여부와 근거를 Progress에 기록.
|
||||||
|
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \
|
||||||
|
--tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected:** 모든 focused/영향 범위 test와 `ktlintCheck`가 exit code `0`으로 끝난다. 홈 팔로잉은 활성 팔로우 AI/DM만 반환하고 일반 채팅 목록은 팔로우 filter 없이 기존 전체 조회를 위임한다.
|
||||||
|
|
||||||
|
수동 검증:
|
||||||
|
|
||||||
|
- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다.
|
||||||
|
- [x] `git diff`에서 endpoint, response DTO, DB schema와 dependency 변경이 없는지 확인한다.
|
||||||
|
- [x] 새 log에 회원·채팅·팔로우 식별자 또는 메시지 본문이 추가되지 않았는지 확인한다.
|
||||||
|
|
||||||
|
전체 회귀 `./gradlew test`는 작은 조회 조건 변경이며 위 test가 service, repository query, facade, endpoint와 인접 일반 채팅 목록을 포함하므로 기본 생략한다. 위 명령으로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 코드로 범위가 확장되면 전체 회귀를 실행하고 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||||
|
|---:|---|---|---|---|
|
||||||
|
| 1 | `P1-T1` | 승인된 PRD | 아니요 | query mapping과 fixture 근거를 PRD·현재 코드와 다시 대조 |
|
||||||
|
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
P1-T1 → P1-GATE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||||
|
- 공개 endpoint, request/response DTO와 chat type filter enum을 변경하지 않는다.
|
||||||
|
- `followingCreators` 최대 20개 결과로 최근 대화를 메모리 후처리하지 않는다.
|
||||||
|
- AI/DM query 전체를 새 method로 복제하지 않는다.
|
||||||
|
- 신규 dependency, DB migration과 index를 추가하지 않는다.
|
||||||
|
- 관련 없는 리팩터링 또는 포맷 변경을 하지 않는다.
|
||||||
|
- test를 삭제·skip·완화하지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- PRD와 구현이 충돌하면 `prd.md`의 Decision Log를 먼저 갱신하고 이 계획을 동기화한 뒤 구현한다.
|
||||||
|
- `ChatCharacter.creatorMember.id` 또는 DM `opponent.member.id`로 활성 팔로우를 판정할 수 없으면 추정 구현을 중단하고 새 근거를 확인한다.
|
||||||
|
- 구현 범위가 공개 API, DB schema 또는 팔로우 쓰기 로직으로 확장되면 사용자 승인 전 진행하지 않는다.
|
||||||
|
- 체크박스, test와 Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### 2026-08-19 `P1-T1`, `P1-GATE` 완료
|
||||||
|
|
||||||
|
- RED: `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember`를 실행해 `$.data.recentChats.length()`가 기대값 `2` 대신 `10`을 반환하는 실패를 확인했다.
|
||||||
|
- GREEN: `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` focused test가 exit code `0`으로 통과했다.
|
||||||
|
- Phase Gate: 위 focused test에 `ChatRoomListControllerTest`를 추가한 영향 범위 회귀와 `./gradlew ktlintCheck`가 exit code `0`으로 통과했다.
|
||||||
|
- 전체 회귀: `./gradlew test`가 exit code `0`으로 통과했다.
|
||||||
|
- 수동 검증: 변경 파일은 계획된 production/test 7개와 이 작업의 `prd.md`, `plan-task.md`뿐이며 endpoint, response DTO, DB schema, dependency와 log 변경이 없음을 `git diff`로 확인했다.
|
||||||
|
- LSP: 로컬에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했으며, Kotlin compile, 전체 test와 ktlint 성공으로 대체 검증했다.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-19 | `DEC-001` | 확정 | DM과 AI 채팅을 모두 활성 팔로우 대상으로 filter한다. | 사용자 인터뷰 승인 | `P1-T1`, PRD `HFC-001~003` |
|
||||||
|
| 2026-08-19 | `DEC-002` | 확정 | 기존 service/query에 기본값 `false`인 최소 옵션을 추가하고 홈 facade만 활성화한다. | 일반 채팅 목록 회귀 없이 기존 병합·정렬을 재사용하는 최소 변경 | `P1-T1` |
|
||||||
|
| 2026-08-19 | `DEC-003` | 확정 | filter는 DB query에서 pagination 전에 적용한다. | 후처리 시 최신 미팔로우 대화가 limit을 차지해 팔로우 대화가 누락될 수 있음 | `P1-T1`, PRD `HFC-004` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
현재 확정된 범위 내 발견된 문제는 없다.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 홈 팔로잉 최근 대화 팔로우 필터 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 기준 확정 |
|
||||||
|
| 작성일 | 2026-08-19 |
|
||||||
|
| 최종 수정일 | 2026-08-19 |
|
||||||
|
| 대상 제품 | 메인 홈 팔로잉 탭 API의 최근 대화 섹션 |
|
||||||
|
| 작성자·결정권자 | Codex 작성, 사용자 승인 |
|
||||||
|
| 선행 PRD | `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` |
|
||||||
|
| 관련 API Contract | 별도 문서 없음. 기존 `GET /api/v2/home/following` 계약을 유지한다. |
|
||||||
|
| 관련 구현 계획 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md` |
|
||||||
|
| 관련 review | 없음 |
|
||||||
|
|
||||||
|
### 선행 문서와의 관계
|
||||||
|
|
||||||
|
- 이 문서는 선행 PRD의 Feature D와 "최근 대화는 팔로잉 여부와 무관하다"는 Edge Case만 대체한다.
|
||||||
|
- 선행 PRD에서 정의한 나머지 팔로잉 탭 요구사항과 완료 기록은 변경하지 않는다.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
로그인 사용자가 메인 홈 팔로잉 탭을 조회할 때 `recentChats`에는 현재 팔로우 중인 크리에이터와의 DM 및 AI 채팅만 노출한다. 기존 응답 스키마, 최신순 정렬, 최대 10개, 메시지 미리보기 정책은 유지한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
- `HomeFollowingFacade`는 현재 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`을 호출한다.
|
||||||
|
- 이 호출은 사용자의 모든 DM 및 AI 채팅방을 조회하므로, 팔로우하지 않은 크리에이터와의 대화도 팔로잉 탭에 노출된다.
|
||||||
|
- 팔로잉 탭의 최근 대화 섹션이 탭의 목적과 다른 대상을 보여준다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 팔로잉 탭 응답에서 활성 팔로우 대상의 DM 및 AI 채팅만 최신순 최대 10개로 반환되고, 일반 채팅 목록 API의 결과는 바뀌지 않는 것으로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
- 팔로잉 탭의 `recentChats`를 현재 활성 팔로우 관계가 있는 크리에이터의 대화로 제한한다.
|
||||||
|
- DM과 AI 채팅을 모두 포함한다.
|
||||||
|
- 팔로우 필터를 저장소 조회에 적용한 뒤 최대 10개를 선택해, 더 최신인 미팔로우 대화 때문에 결과 수가 줄지 않게 한다.
|
||||||
|
- 기존 공개 API 스키마, 정렬, 미리보기, 최대 개수 정책을 유지한다.
|
||||||
|
- 일반 채팅 목록 API의 기존 전체 조회 동작을 유지한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- `GET /api/v2/home/following` 또는 `GET /api/v2/chat/rooms`의 request/response 스키마를 변경하지 않는다.
|
||||||
|
- 채팅방 생성, 메시지 전송, 읽음 처리, cursor 형식과 메시지 미리보기 정책을 변경하지 않는다.
|
||||||
|
- 팔로우/언팔로우 처리 자체와 알림 설정 정책을 변경하지 않는다.
|
||||||
|
- 차단 관계, 크리에이터 계정 활성 상태 등 이번 요청에 포함되지 않은 추가 노출 조건을 도입하지 않는다.
|
||||||
|
- DB schema, index, dependency를 추가하지 않는다.
|
||||||
|
- 선행 홈 팔로잉 PRD의 최근 대화 이외 섹션을 변경하지 않는다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
| 사용자 | 기대 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 로그인 회원 | 팔로우 중인 크리에이터와의 최근 DM 및 AI 채팅을 확인한다. |
|
||||||
|
| 비로그인 사용자 | 기존과 동일하게 로그인 필요 상태와 빈 `recentChats`를 받는다. |
|
||||||
|
| 앱 클라이언트 | 변경 없는 응답 스키마로 최근 대화 섹션을 표시한다. |
|
||||||
|
|
||||||
|
- 인증 회원의 `Member.id`를 팔로우 관계와 채팅 참가자 조회 기준으로 사용한다.
|
||||||
|
- 비로그인 요청에서는 기존과 동일하게 채팅 조회를 실행하지 않는다.
|
||||||
|
|
||||||
|
## 6. 핵심 사용자 흐름
|
||||||
|
|
||||||
|
1. 사용자가 `GET /api/v2/home/following`을 호출한다.
|
||||||
|
2. 서버는 로그인 회원의 홈 팔로잉 데이터와 최근 대화를 조회한다.
|
||||||
|
3. 최근 대화 조회는 현재 `CreatorFollowing.isActive = true`인 크리에이터의 채팅만 선택한다.
|
||||||
|
4. DM과 AI 결과를 기존 정렬 규칙으로 병합하고 최대 10개를 반환한다.
|
||||||
|
5. 앱은 기존 `recentChats` 응답 필드로 채팅방 진입 화면을 구성한다.
|
||||||
|
|
||||||
|
## 7. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `HFC-001` | 확정 | 팔로잉 탭의 최근 대화는 요청 회원이 현재 활성 팔로우 중인 크리에이터의 채팅만 포함한다. | 활성 팔로우 대상은 포함되고 팔로우 row가 없거나 `isActive = false`인 대상은 제외된다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `HFC-002` | 확정 | DM 채팅의 크리에이터 식별자는 `UserCreatorChatParticipant`의 상대 회원 `opponent.member.id`를 사용한다. | 상대 회원과 요청 회원의 활성 `CreatorFollowing`이 있을 때만 DM 방이 반환된다. | `P1-T1` |
|
||||||
|
| `HFC-003` | 확정 | AI 채팅의 크리에이터 식별자는 `ChatCharacter.creatorMember.id`를 사용한다. | AI 캐릭터의 `creatorMember`와 요청 회원의 활성 `CreatorFollowing`이 있을 때만 AI 방이 반환된다. | `P1-T1` |
|
||||||
|
| `HFC-004` | 확정 | 팔로우 필터는 저장소 조회에서 pagination보다 먼저 적용한다. | 최신 미팔로우 대화가 10개 이상이어도 그보다 오래된 팔로우 대화를 최대 10개까지 조회할 수 있다. | `P1-T1` |
|
||||||
|
| `HFC-005` | 확정 | DM과 AI 결과는 기존 `lastMessageAt`, `chatType`, `roomId` 내림차순으로 병합하고 최대 10개를 반환한다. | 기존 정렬·limit·`ChatRoomListItemResponse` 변환 테스트가 통과한다. | `P1-T1` |
|
||||||
|
| `HFC-006` | 확정 | 일반 채팅 목록은 기존처럼 팔로잉 여부와 무관하게 조회한다. | `GET /api/v2/chat/rooms`가 팔로우 필터를 요청하지 않고 기존 서비스 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
|
||||||
|
| `HFC-007` | 확정 | 비로그인 홈 팔로잉 요청 동작은 유지한다. | `isLoginRequired = true`, 빈 `recentChats`, 채팅 서비스 미호출이 유지된다. | `P1-GATE` |
|
||||||
|
| `HFC-008` | 확정 | 홈 팔로잉의 크리에이터 목록 최대 20개와 관계없이 모든 활성 팔로우 관계를 최근 대화 필터에 사용한다. | `followingCreators` 응답 목록을 후처리 필터로 재사용하지 않고 DB의 활성 팔로우 관계를 직접 판정한다. | `P1-T1` |
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- 활성 팔로우 관계가 없으면 `recentChats`는 빈 배열이다.
|
||||||
|
- 과거에 팔로우했더라도 현재 `CreatorFollowing.isActive = false`이면 해당 대화를 제외한다.
|
||||||
|
- 활성 팔로우 관계가 있는 AI와 DM 대화가 함께 있으면 두 유형 모두 기존 최신순 정렬에 포함한다.
|
||||||
|
- 방, 참가자 또는 메시지가 비활성인 경우 기존 채팅 목록 조회 조건대로 제외한다.
|
||||||
|
- 메시지가 없는 방은 기존 채팅 목록 정책대로 최근 대화에 포함하지 않는다.
|
||||||
|
|
||||||
|
## 8. API 계약
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
- Method/Path: `GET /api/v2/home/following`
|
||||||
|
- request parameter: 변경 없음
|
||||||
|
- 인증 처리: 변경 없음
|
||||||
|
- response wrapper: 변경 없음
|
||||||
|
- `recentChats`: 기존 `List<ChatRoomListItemResponse>` 유지
|
||||||
|
- `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`: 필드와 의미 변경 없음
|
||||||
|
|
||||||
|
`GET /api/v2/chat/rooms`의 공개 계약과 전체 조회 의미도 변경하지 않는다.
|
||||||
|
|
||||||
|
## 9. 데이터·보안·성능 요구사항
|
||||||
|
|
||||||
|
- 팔로우 판정은 `creator_following.member_id`, `creator_following.creator_id`, `creator_following.is_active = true`를 사용한다.
|
||||||
|
- `creator_following`의 기존 `(member_id, creator_id)` unique constraint를 활용하고 신규 index를 추가하지 않는다.
|
||||||
|
- 팔로우 여부는 응답 생성 시점의 DB 상태를 기준으로 한다.
|
||||||
|
- 회원·채팅·팔로우 식별자와 메시지 본문을 새 log로 남기지 않는다.
|
||||||
|
- 팔로우 필터는 DB query에 포함해 미팔로우 결과를 메모리에서 제거하거나 전체 대화를 로드하지 않는다.
|
||||||
|
- 신규 dependency와 DB migration을 추가하지 않는다.
|
||||||
|
|
||||||
|
## 10. 성공 기준
|
||||||
|
|
||||||
|
- [x] 활성 팔로우 중인 일반 크리에이터와의 DM이 `recentChats`에 포함된다. (`HFC-001`, `HFC-002`)
|
||||||
|
- [x] 활성 팔로우 중인 AI 캐릭터와의 AI 채팅이 `recentChats`에 포함된다. (`HFC-001`, `HFC-003`)
|
||||||
|
- [x] 팔로우하지 않았거나 현재 비활성 팔로우인 크리에이터의 대화는 제외된다. (`HFC-001`)
|
||||||
|
- [x] 필터 적용 후 최신순 최대 10개와 기존 응답 필드가 유지된다. (`HFC-004`, `HFC-005`)
|
||||||
|
- [x] 일반 채팅 목록과 비로그인 홈 팔로잉 응답이 회귀하지 않는다. (`HFC-006`, `HFC-007`)
|
||||||
|
- [x] 공개 API schema, DB schema와 dependency 변경이 없다.
|
||||||
|
|
||||||
|
## 11. Open Questions
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
인터뷰 종료 시 최종 모호성은 `0.07`이며, 명확성 점수는 Goal `1.00`, Scope `1.00`, Constraints `1.00`, Success `0.75`, Context `0.90`이다.
|
||||||
|
|
||||||
|
## 12. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| `HFC-001~005`, `HFC-008` | 1 | `P1-T1` | `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` |
|
||||||
|
| `HFC-006~007` | 1 | `P1-GATE` | `ChatRoomListControllerTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` |
|
||||||
|
|
||||||
|
## 13. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-19 | `DEC-001` | 확정 | 최근 대화는 DM과 AI 채팅을 모두 포함한다. | 사용자는 AI 채팅 가능 캐릭터도 크리에이터라고 확정했다. | `HFC-001~003`, `P1-T1` |
|
||||||
|
| 2026-08-19 | `DEC-002` | 확정 | 일반 채팅 목록은 유지하고 홈 팔로잉 조회에서만 활성 팔로우 필터를 사용한다. | 변경 범위를 홈 팔로잉 탭으로 제한하고 기존 공개 API 회귀를 방지한다. | `HFC-006`, `P1-T1` |
|
||||||
|
| 2026-08-19 | `DEC-003` | 확정 | 기존 완료 문서는 유지하고 이 PRD가 최근 대화 규칙만 대체한다. | 완료 이력을 보존하면서 새 변경의 범위와 검증을 독립적으로 추적한다. | 문서 전체 |
|
||||||
@@ -87,6 +87,15 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
|||||||
AND pc.isActive = true
|
AND pc.isActive = true
|
||||||
AND r.isActive = true
|
AND r.isActive = true
|
||||||
AND m.isActive = true
|
AND m.isActive = true
|
||||||
|
AND (
|
||||||
|
:followedCreatorsOnly = false
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT cf.id FROM CreatorFollowing cf
|
||||||
|
WHERE cf.member.id = :memberId
|
||||||
|
AND cf.creator.id = pc.character.creatorMember.id
|
||||||
|
AND cf.isActive = true
|
||||||
|
)
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
:cursorAt IS NULL
|
:cursorAt IS NULL
|
||||||
OR m.createdAt < :cursorAt
|
OR m.createdAt < :cursorAt
|
||||||
@@ -110,6 +119,7 @@ interface ChatRoomRepository : JpaRepository<ChatRoom, Long> {
|
|||||||
@Param("cursorAt") cursorAt: LocalDateTime?,
|
@Param("cursorAt") cursorAt: LocalDateTime?,
|
||||||
@Param("cursorChatType") cursorChatType: String?,
|
@Param("cursorChatType") cursorChatType: String?,
|
||||||
@Param("cursorRoomId") cursorRoomId: Long?,
|
@Param("cursorRoomId") cursorRoomId: Long?,
|
||||||
|
@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): List<V2ChatRoomListQueryDto>
|
): List<V2ChatRoomListQueryDto>
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ import kr.co.vividnext.sodalive.member.Member
|
|||||||
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
|
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L, 17958L, 44144L)
|
||||||
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
|
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L)
|
||||||
|
|
||||||
fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String {
|
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
|
||||||
val memberId = member.id
|
val memberId = member?.id
|
||||||
if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) {
|
if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) {
|
||||||
return "KR"
|
return "KR"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -157,8 +157,8 @@ class MemberContentPreferenceService(
|
|||||||
return getStoredPreference(member).isAdult
|
return getStoredPreference(member).isAdult
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resolveCountryCode(member: Member): String {
|
fun resolveCountryCode(member: Member?): String {
|
||||||
requireMemberId(member)
|
if (member != null) requireMemberId(member)
|
||||||
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
|
||||||
@@ -46,6 +47,7 @@ class HomeRecommendationFacade(
|
|||||||
return runCatching {
|
return runCatching {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val includeAdult = resolveAdultVisibility(member)
|
val includeAdult = resolveAdultVisibility(member)
|
||||||
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
|
|
||||||
HomeRecommendationResponse(
|
HomeRecommendationResponse(
|
||||||
lives = queryService.findLiveRecommendations(
|
lives = queryService.findLiveRecommendations(
|
||||||
@@ -56,7 +58,8 @@ class HomeRecommendationFacade(
|
|||||||
banners = queryService.findHomeBanners(
|
banners = queryService.findHomeBanners(
|
||||||
limit = HOME_BANNER_LIMIT,
|
limit = HOME_BANNER_LIMIT,
|
||||||
memberId = member?.id,
|
memberId = member?.id,
|
||||||
includeAdultBanners = includeAdult
|
includeAdultBanners = includeAdult,
|
||||||
|
lang = bannerLang
|
||||||
)
|
)
|
||||||
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
.map { RecommendationBannerResponse.from(it.toBanner()) },
|
||||||
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
recentlyActiveCreators = queryService.findRecentlyActiveCreators(
|
||||||
|
|||||||
+7
-1
@@ -17,7 +17,13 @@ class HomeFollowingFacade(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val home = homeFollowingQueryService.findHomeFollowing(member)
|
val home = homeFollowingQueryService.findHomeFollowing(member)
|
||||||
val recentChats = chatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10).rooms
|
val recentChats = chatRoomListService.getRooms(
|
||||||
|
member,
|
||||||
|
filter = "ALL",
|
||||||
|
cursor = null,
|
||||||
|
limit = 10,
|
||||||
|
followedCreatorsOnly = true
|
||||||
|
).rooms
|
||||||
|
|
||||||
return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
|
return HomeFollowingTabResponse.from(home.copy(recentChats = recentChats))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class ChatRoomListService(
|
|||||||
member: Member,
|
member: Member,
|
||||||
filter: String = ChatRoomListFilter.ALL.name,
|
filter: String = ChatRoomListFilter.ALL.name,
|
||||||
cursor: String? = null,
|
cursor: String? = null,
|
||||||
limit: Int = DEFAULT_LIMIT
|
limit: Int = DEFAULT_LIMIT,
|
||||||
|
followedCreatorsOnly: Boolean = false
|
||||||
): ChatRoomListPageResponse {
|
): ChatRoomListPageResponse {
|
||||||
val type = ChatRoomListFilter.from(filter)
|
val type = ChatRoomListFilter.from(filter)
|
||||||
val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT)
|
val safeLimit = limit.coerceIn(1, DEFAULT_LIMIT)
|
||||||
@@ -41,12 +42,14 @@ class ChatRoomListService(
|
|||||||
parsedCursor?.lastMessageAt,
|
parsedCursor?.lastMessageAt,
|
||||||
parsedCursor?.chatType,
|
parsedCursor?.chatType,
|
||||||
parsedCursor?.roomId,
|
parsedCursor?.roomId,
|
||||||
|
followedCreatorsOnly,
|
||||||
pageable
|
pageable
|
||||||
) + dmRoomRepository.findDmChatListRooms(
|
) + dmRoomRepository.findDmChatListRooms(
|
||||||
member.id!!,
|
member.id!!,
|
||||||
parsedCursor?.lastMessageAt,
|
parsedCursor?.lastMessageAt,
|
||||||
parsedCursor?.chatType,
|
parsedCursor?.chatType,
|
||||||
parsedCursor?.roomId,
|
parsedCursor?.roomId,
|
||||||
|
followedCreatorsOnly,
|
||||||
pageable
|
pageable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,7 @@ class ChatRoomListService(
|
|||||||
parsedCursor?.lastMessageAt,
|
parsedCursor?.lastMessageAt,
|
||||||
parsedCursor?.chatType,
|
parsedCursor?.chatType,
|
||||||
parsedCursor?.roomId,
|
parsedCursor?.roomId,
|
||||||
|
followedCreatorsOnly,
|
||||||
pageable
|
pageable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,7 @@ class ChatRoomListService(
|
|||||||
parsedCursor?.lastMessageAt,
|
parsedCursor?.lastMessageAt,
|
||||||
parsedCursor?.chatType,
|
parsedCursor?.chatType,
|
||||||
parsedCursor?.roomId,
|
parsedCursor?.roomId,
|
||||||
|
followedCreatorsOnly,
|
||||||
pageable
|
pageable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -15,6 +15,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
|||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.QMember
|
import kr.co.vividnext.sodalive.member.QMember
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
@@ -40,7 +41,12 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) : AudioRecommendationQueryRepository {
|
) : AudioRecommendationQueryRepository {
|
||||||
override fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner> {
|
override fun findBanners(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
lang: Lang
|
||||||
|
): List<RecommendationBanner> {
|
||||||
val bannerCreator = QMember("audioRecommendationBannerCreator")
|
val bannerCreator = QMember("audioRecommendationBannerCreator")
|
||||||
val seriesOwner = QMember("audioRecommendationSeriesOwner")
|
val seriesOwner = QMember("audioRecommendationSeriesOwner")
|
||||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||||
@@ -64,6 +70,7 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.where(
|
.where(
|
||||||
audioContentBanner.isActive.isTrue,
|
audioContentBanner.isActive.isTrue,
|
||||||
audioContentBanner.tab.id.eq(2L),
|
audioContentBanner.tab.id.eq(2L),
|
||||||
|
audioContentBanner.lang.eq(lang),
|
||||||
adultBannerCondition(canViewAdultContent),
|
adultBannerCondition(canViewAdultContent),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
)
|
)
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||||
@@ -24,6 +25,7 @@ class AudioRecommendationQueryService(
|
|||||||
val canViewAdultContent = canViewAdultContent(member)
|
val canViewAdultContent = canViewAdultContent(member)
|
||||||
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE
|
||||||
val memberId = member?.id
|
val memberId = member?.id
|
||||||
|
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||||
val newAndHotSectionType = newAndHotSectionType(visibility)
|
val newAndHotSectionType = newAndHotSectionType(visibility)
|
||||||
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
val mostCommentedSectionType = mostCommentedSectionType(visibility)
|
||||||
val recommendedAudioSectionType = recommendedAudioSectionType(visibility)
|
val recommendedAudioSectionType = recommendedAudioSectionType(visibility)
|
||||||
@@ -44,7 +46,7 @@ class AudioRecommendationQueryService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return AudioRecommendations(
|
return AudioRecommendations(
|
||||||
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
|
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang),
|
||||||
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
||||||
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||||
newAndHotAudios = queryPort.findAudioCardsByIds(
|
newAndHotAudios = queryPort.findAudioCardsByIds(
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
|
package kr.co.vividnext.sodalive.v2.content.recommendation.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||||
@@ -9,7 +10,12 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
interface AudioRecommendationQueryPort {
|
interface AudioRecommendationQueryPort {
|
||||||
fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner>
|
fun findBanners(
|
||||||
|
limit: Int,
|
||||||
|
memberId: Long?,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
lang: Lang = Lang.KO
|
||||||
|
): List<RecommendationBanner>
|
||||||
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
fun findOriginalSeries(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<OriginalSeries>
|
||||||
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
fun findLatestAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||||
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
fun findFreeAudios(limit: Int, memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): List<AudioCard>
|
||||||
|
|||||||
+4
-1
@@ -20,6 +20,7 @@ import kr.co.vividnext.sodalive.event.QEvent.event
|
|||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
import kr.co.vividnext.sodalive.member.MemberKind
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -88,7 +89,8 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
override fun findHomeBanners(
|
override fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultBanners: Boolean
|
includeAdultBanners: Boolean,
|
||||||
|
lang: Lang
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
val bannerCreator = QMember("bannerCreator")
|
val bannerCreator = QMember("bannerCreator")
|
||||||
val seriesOwner = QMember("seriesOwner")
|
val seriesOwner = QMember("seriesOwner")
|
||||||
@@ -116,6 +118,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.where(
|
.where(
|
||||||
audioContentBanner.isActive.isTrue,
|
audioContentBanner.isActive.isTrue,
|
||||||
audioContentBanner.tab.isNull,
|
audioContentBanner.tab.isNull,
|
||||||
|
audioContentBanner.lang.eq(lang),
|
||||||
includeAdultBannerCondition(includeAdultBanners),
|
includeAdultBannerCondition(includeAdultBanners),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
@@ -44,9 +45,10 @@ class HomeRecommendationQueryService(
|
|||||||
fun findHomeBanners(
|
fun findHomeBanners(
|
||||||
limit: Int = DEFAULT_BANNER_LIMIT,
|
limit: Int = DEFAULT_BANNER_LIMIT,
|
||||||
memberId: Long? = null,
|
memberId: Long? = null,
|
||||||
includeAdultBanners: Boolean = false
|
includeAdultBanners: Boolean = false,
|
||||||
|
lang: Lang = Lang.KO
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
return queryPort.findHomeBanners(limit, memberId, includeAdultBanners)
|
return queryPort.findHomeBanners(limit, memberId, includeAdultBanners, lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findRecentlyActiveCreators(
|
fun findRecentlyActiveCreators(
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
package kr.co.vividnext.sodalive.v2.recommendation.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ interface HomeRecommendationQueryPort {
|
|||||||
fun findHomeBanners(
|
fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long? = null,
|
memberId: Long? = null,
|
||||||
includeAdultBanners: Boolean = false
|
includeAdultBanners: Boolean = false,
|
||||||
|
lang: Lang = Lang.KO
|
||||||
): List<HomeBannerRecommendationRecord>
|
): List<HomeBannerRecommendationRecord>
|
||||||
|
|
||||||
fun findRecentlyActiveCreators(
|
fun findRecentlyActiveCreators(
|
||||||
|
|||||||
+10
@@ -57,6 +57,15 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
|
|||||||
and opponent.isActive = true
|
and opponent.isActive = true
|
||||||
and opponent.member.id <> :memberId
|
and opponent.member.id <> :memberId
|
||||||
and m.isActive = true
|
and m.isActive = true
|
||||||
|
and (
|
||||||
|
:followedCreatorsOnly = false
|
||||||
|
or exists (
|
||||||
|
select cf.id from CreatorFollowing cf
|
||||||
|
where cf.member.id = :memberId
|
||||||
|
and cf.creator.id = opponent.member.id
|
||||||
|
and cf.isActive = true
|
||||||
|
)
|
||||||
|
)
|
||||||
and (
|
and (
|
||||||
:cursorAt is null
|
:cursorAt is null
|
||||||
or m.createdAt < :cursorAt
|
or m.createdAt < :cursorAt
|
||||||
@@ -80,6 +89,7 @@ interface UserCreatorChatRoomRepository : JpaRepository<UserCreatorChatRoom, Lon
|
|||||||
@Param("cursorAt") cursorAt: LocalDateTime?,
|
@Param("cursorAt") cursorAt: LocalDateTime?,
|
||||||
@Param("cursorChatType") cursorChatType: String?,
|
@Param("cursorChatType") cursorChatType: String?,
|
||||||
@Param("cursorRoomId") cursorRoomId: Long?,
|
@Param("cursorRoomId") cursorRoomId: Long?,
|
||||||
|
@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): List<ChatRoomListQueryDto>
|
): List<ChatRoomListQueryDto>
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -207,6 +207,22 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor(
|
|||||||
assertEquals("KR", service.resolveCountryCode(member))
|
assertEquals("KR", service.resolveCountryCode(member))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("비로그인 사용자의 국가 코드는 접속 국가 헤더를 정규화하고 없으면 KR로 계산된다")
|
||||||
|
fun shouldResolveCountryCodeForAnonymousUser() {
|
||||||
|
countryContext.setCountryCode("JP")
|
||||||
|
assertEquals("JP", service.resolveCountryCode(null))
|
||||||
|
|
||||||
|
countryContext.setCountryCode(" jp ")
|
||||||
|
assertEquals("JP", service.resolveCountryCode(null))
|
||||||
|
|
||||||
|
countryContext.setCountryCode(null)
|
||||||
|
assertEquals("KR", service.resolveCountryCode(null))
|
||||||
|
|
||||||
|
countryContext.setCountryCode(" ")
|
||||||
|
assertEquals("KR", service.resolveCountryCode(null))
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(seed: String): Member {
|
private fun saveMember(seed: String): Member {
|
||||||
return memberRepository.saveAndFlush(
|
return memberRepository.saveAndFlush(
|
||||||
Member(
|
Member(
|
||||||
|
|||||||
+26
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.application
|
package kr.co.vividnext.sodalive.v2.api.home.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
|
||||||
@@ -56,6 +57,27 @@ class HomeRecommendationFacadeTest {
|
|||||||
assertTrue(queryPort.bannerIncludeAdultBanners)
|
assertTrue(queryPort.bannerIncludeAdultBanners)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 추천은 JP 국가 판정이면 JA 배너 조회를 요청한다")
|
||||||
|
fun shouldPassJaToHomeBannerQueryForJapan() {
|
||||||
|
val member = Member(password = "password", nickname = "jp-member").apply { id = 11L }
|
||||||
|
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||||
|
|
||||||
|
facade.getHomeRecommendations(member)
|
||||||
|
|
||||||
|
assertTrue(queryPort.bannerLang == Lang.JA)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 추천은 JP 이외 국가 판정이면 KO 배너 조회를 요청한다")
|
||||||
|
fun shouldPassKoToHomeBannerQueryForNonJapan() {
|
||||||
|
Mockito.doReturn("KR").`when`(preferenceService).resolveCountryCode(null)
|
||||||
|
|
||||||
|
facade.getHomeRecommendations(null)
|
||||||
|
|
||||||
|
assertTrue(queryPort.bannerLang == Lang.KO)
|
||||||
|
}
|
||||||
|
|
||||||
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
|
||||||
return HomeFirstAudioContentRecord(
|
return HomeFirstAudioContentRecord(
|
||||||
contentId = price.toLong() + 1,
|
contentId = price.toLong() + 1,
|
||||||
@@ -74,6 +96,7 @@ class HomeRecommendationFacadeTest {
|
|||||||
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
|
||||||
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
|
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
|
||||||
var bannerIncludeAdultBanners: Boolean = false
|
var bannerIncludeAdultBanners: Boolean = false
|
||||||
|
var bannerLang: Lang? = null
|
||||||
|
|
||||||
override fun findLiveRecommendations(
|
override fun findLiveRecommendations(
|
||||||
offset: Long,
|
offset: Long,
|
||||||
@@ -85,9 +108,11 @@ class HomeRecommendationFacadeTest {
|
|||||||
override fun findHomeBanners(
|
override fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultBanners: Boolean
|
includeAdultBanners: Boolean,
|
||||||
|
lang: Lang
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
bannerIncludeAdultBanners = includeAdultBanners
|
bannerIncludeAdultBanners = includeAdultBanners
|
||||||
|
bannerLang = lang
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+78
-10
@@ -1,5 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
|
package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ChatRoom
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
||||||
import kr.co.vividnext.sodalive.common.CountryContext
|
import kr.co.vividnext.sodalive.common.CountryContext
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
@@ -9,6 +14,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo
|
|||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberAdapter
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
@@ -85,10 +91,19 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator"))
|
.andExpect(jsonPath("$.data.followingCreators[0].creatorNickname").value("home-following-creator"))
|
||||||
.andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId))
|
.andExpect(jsonPath("$.data.onAirLives[0].liveId").value(fixture.liveId))
|
||||||
.andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live"))
|
.andExpect(jsonPath("$.data.onAirLives[0].title").value("home-following-live"))
|
||||||
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.chatRoomId))
|
.andExpect(jsonPath("$.data.recentChats.length()").value(2))
|
||||||
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("DM"))
|
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId))
|
||||||
.andExpect(jsonPath("$.data.recentChats[0].targetName").value("home-following-creator"))
|
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI"))
|
||||||
.andExpect(jsonPath("$.data.recentChats[0].lastMessage").value("recent dm"))
|
.andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM"))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[1].targetName").value("home-following-creator"))
|
||||||
|
.andExpect(jsonPath("$.data.recentChats[1].lastMessage").value("recent dm"))
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty
|
||||||
|
)
|
||||||
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
|
.andExpect(jsonPath("$.data.monthlySchedules[0].scheduleId").value("LIVE:${fixture.liveId}"))
|
||||||
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
|
.andExpect(jsonPath("$.data.monthlySchedules[1].scheduleId").value("AUDIO:${fixture.audioId}"))
|
||||||
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
|
.andExpect(jsonPath("$.data.recentNews[0].newsId").value(fixture.rankedNewsId.toString()))
|
||||||
@@ -172,7 +187,21 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
val rankedVisibleFromAtUtc = now.minusHours(1)
|
val rankedVisibleFromAtUtc = now.minusHours(1)
|
||||||
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
|
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
|
||||||
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
|
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
|
||||||
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
|
val dmChatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
|
||||||
|
val aiCreator = saveMember(
|
||||||
|
"home-following-ai-creator",
|
||||||
|
MemberRole.CREATOR,
|
||||||
|
memberKind = MemberKind.AI_CHARACTER
|
||||||
|
)
|
||||||
|
saveFollowing(viewer, aiCreator)
|
||||||
|
val aiChatRoom = saveAiChatRoom(viewer, aiCreator, now.minusMinutes(5))
|
||||||
|
val unfollowedDmRoomIds = (1..11).map { index ->
|
||||||
|
val unfollowedCreator = saveMember("home-following-unfollowed-$index", MemberRole.CREATOR)
|
||||||
|
saveDmChatRoom(viewer, unfollowedCreator, now.minusSeconds(index.toLong())).id!!
|
||||||
|
}
|
||||||
|
val inactiveFollowingCreator = saveMember("home-following-inactive", MemberRole.CREATOR)
|
||||||
|
saveFollowing(viewer, inactiveFollowingCreator, isActive = false)
|
||||||
|
val inactiveFollowingDmRoom = saveDmChatRoom(viewer, inactiveFollowingCreator, now.minusMinutes(1))
|
||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
|
|
||||||
@@ -181,7 +210,10 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
creatorId = creator.id!!,
|
creatorId = creator.id!!,
|
||||||
liveId = live.id!!,
|
liveId = live.id!!,
|
||||||
audioId = audio.id!!,
|
audioId = audio.id!!,
|
||||||
chatRoomId = chatRoom.id!!,
|
dmChatRoomId = dmChatRoom.id!!,
|
||||||
|
aiChatRoomId = aiChatRoom.id!!,
|
||||||
|
unfollowedDmRoomIds = unfollowedDmRoomIds,
|
||||||
|
inactiveFollowingDmRoomId = inactiveFollowingDmRoom.id!!,
|
||||||
rankedNewsId = rankedNews.id!!,
|
rankedNewsId = rankedNews.id!!,
|
||||||
audioNewsId = audioNews.id!!,
|
audioNewsId = audioNews.id!!,
|
||||||
communityNewsId = communityNews.id!!,
|
communityNewsId = communityNews.id!!,
|
||||||
@@ -191,21 +223,27 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
}!!
|
}!!
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null): Member {
|
private fun saveMember(
|
||||||
|
seed: String,
|
||||||
|
role: MemberRole,
|
||||||
|
profileImage: String? = null,
|
||||||
|
memberKind: MemberKind = MemberKind.HUMAN
|
||||||
|
): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$seed@test.com",
|
email = "$seed@test.com",
|
||||||
password = "password",
|
password = "password",
|
||||||
nickname = seed,
|
nickname = seed,
|
||||||
profileImage = profileImage,
|
profileImage = profileImage,
|
||||||
role = role,
|
role = role,
|
||||||
|
memberKind = memberKind,
|
||||||
countryCode = "US"
|
countryCode = "US"
|
||||||
)
|
)
|
||||||
entityManager.persist(member)
|
entityManager.persist(member)
|
||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
|
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing {
|
||||||
val following = CreatorFollowing(isActive = true).apply {
|
val following = CreatorFollowing(isActive = isActive).apply {
|
||||||
this.member = member
|
this.member = member
|
||||||
this.creator = creator
|
this.creator = creator
|
||||||
}
|
}
|
||||||
@@ -354,12 +392,42 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
|||||||
return room
|
return room
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveAiChatRoom(viewer: Member, creator: Member, messageCreatedAt: LocalDateTime): ChatRoom {
|
||||||
|
val character = ChatCharacter(
|
||||||
|
characterUUID = "home-following-ai-character-uuid",
|
||||||
|
name = "home-following-ai-character",
|
||||||
|
description = "description",
|
||||||
|
systemPrompt = "system"
|
||||||
|
).apply { creatorMember = creator }
|
||||||
|
entityManager.persist(character)
|
||||||
|
val room = ChatRoom(sessionId = "home-following-ai-room", title = "home-following-ai-room")
|
||||||
|
entityManager.persist(room)
|
||||||
|
val viewerParticipant = ChatParticipant(room, ParticipantType.USER, member = viewer)
|
||||||
|
val characterParticipant = ChatParticipant(room, ParticipantType.CHARACTER, character = character)
|
||||||
|
entityManager.persist(viewerParticipant)
|
||||||
|
entityManager.persist(characterParticipant)
|
||||||
|
val message = ChatMessage(
|
||||||
|
message = "recent ai",
|
||||||
|
chatRoom = room,
|
||||||
|
participant = characterParticipant,
|
||||||
|
isActive = true
|
||||||
|
)
|
||||||
|
entityManager.persist(message)
|
||||||
|
entityManager.flush()
|
||||||
|
message.createdAt = messageCreatedAt
|
||||||
|
message.updatedAt = messageCreatedAt
|
||||||
|
return room
|
||||||
|
}
|
||||||
|
|
||||||
private data class Fixture(
|
private data class Fixture(
|
||||||
val viewer: Member,
|
val viewer: Member,
|
||||||
val creatorId: Long,
|
val creatorId: Long,
|
||||||
val liveId: Long,
|
val liveId: Long,
|
||||||
val audioId: Long,
|
val audioId: Long,
|
||||||
val chatRoomId: Long,
|
val dmChatRoomId: Long,
|
||||||
|
val aiChatRoomId: Long,
|
||||||
|
val unfollowedDmRoomIds: List<Long>,
|
||||||
|
val inactiveFollowingDmRoomId: Long,
|
||||||
val rankedNewsId: Long,
|
val rankedNewsId: Long,
|
||||||
val audioNewsId: Long,
|
val audioNewsId: Long,
|
||||||
val communityNewsId: Long,
|
val communityNewsId: Long,
|
||||||
|
|||||||
+4
-2
@@ -60,7 +60,8 @@ class HomeFollowingFacadeTest {
|
|||||||
)
|
)
|
||||||
Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member)
|
Mockito.doReturn(home).`when`(queryService).findHomeFollowing(member)
|
||||||
Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null))
|
Mockito.doReturn(ChatRoomListPageResponse(rooms = listOf(recentChat), hasMore = false, nextCursor = null))
|
||||||
.`when`(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
|
.`when`(chatRoomListService)
|
||||||
|
.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
|
||||||
|
|
||||||
val response = facade.getFollowingTab(member)
|
val response = facade.getFollowingTab(member)
|
||||||
|
|
||||||
@@ -71,7 +72,8 @@ class HomeFollowingFacadeTest {
|
|||||||
assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId)
|
assertEquals("LIVE:4", response.monthlySchedules.single().scheduleId)
|
||||||
assertEquals("news-5", response.recentNews.single().newsId)
|
assertEquals("news-5", response.recentNews.single().newsId)
|
||||||
Mockito.verify(queryService).findHomeFollowing(member)
|
Mockito.verify(queryService).findHomeFollowing(member)
|
||||||
Mockito.verify(chatRoomListService).getRooms(member, filter = "ALL", cursor = null, limit = 10)
|
Mockito.verify(chatRoomListService)
|
||||||
|
.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun homeFollowing(): HomeFollowing {
|
private fun homeFollowing(): HomeFollowing {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ class ChatRoomListServiceTest {
|
|||||||
val member = member(1L)
|
val member = member(1L)
|
||||||
val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0)
|
val aiLastAt = LocalDateTime.of(2026, 5, 14, 11, 0)
|
||||||
val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0)
|
val dmLastAt = LocalDateTime.of(2026, 5, 14, 12, 0)
|
||||||
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
|
Mockito.`when`(
|
||||||
|
aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
ChatRoomListQueryDto(
|
ChatRoomListQueryDto(
|
||||||
roomId = 10L,
|
roomId = 10L,
|
||||||
@@ -52,7 +54,9 @@ class ChatRoomListServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
|
Mockito.`when`(
|
||||||
|
dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
ChatRoomListQueryDto(
|
ChatRoomListQueryDto(
|
||||||
roomId = 20L,
|
roomId = 20L,
|
||||||
@@ -80,7 +84,9 @@ class ChatRoomListServiceTest {
|
|||||||
@DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다")
|
@DisplayName("DM 필터는 DM 방만 조회하고 음성 메시지 요약 문구를 사용한다")
|
||||||
fun shouldReturnOnlyDmRoomsWithVoicePreview() {
|
fun shouldReturnOnlyDmRoomsWithVoicePreview() {
|
||||||
val member = member(1L)
|
val member = member(1L)
|
||||||
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(
|
Mockito.`when`(
|
||||||
|
dmRoomRepository.findDmChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
ChatRoomListQueryDto(
|
ChatRoomListQueryDto(
|
||||||
roomId = 20L,
|
roomId = 20L,
|
||||||
@@ -118,7 +124,9 @@ class ChatRoomListServiceTest {
|
|||||||
lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index)
|
lastMessageAt = LocalDateTime.of(2026, 5, 14, 12, 0).minusMinutes(index)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, PageRequest.of(0, 31))).thenReturn(rows)
|
Mockito.`when`(
|
||||||
|
aiRoomRepository.findAiChatListRooms(1L, null, null, null, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(rows)
|
||||||
|
|
||||||
val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30)
|
val response = service.getRooms(member, filter = "AI", cursor = null, limit = 30)
|
||||||
|
|
||||||
@@ -133,7 +141,9 @@ class ChatRoomListServiceTest {
|
|||||||
fun shouldKeepRoomsWithSameTimestampAfterCursor() {
|
fun shouldKeepRoomsWithSameTimestampAfterCursor() {
|
||||||
val member = member(1L)
|
val member = member(1L)
|
||||||
val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0)
|
val cursorAt = LocalDateTime.of(2026, 5, 14, 12, 0)
|
||||||
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
|
Mockito.`when`(
|
||||||
|
aiRoomRepository.findAiChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
ChatRoomListQueryDto(
|
ChatRoomListQueryDto(
|
||||||
roomId = 30L,
|
roomId = 30L,
|
||||||
@@ -146,7 +156,9 @@ class ChatRoomListServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, PageRequest.of(0, 31))).thenReturn(
|
Mockito.`when`(
|
||||||
|
dmRoomRepository.findDmChatListRooms(1L, cursorAt, "DM", 20L, false, PageRequest.of(0, 31))
|
||||||
|
).thenReturn(
|
||||||
listOf(
|
listOf(
|
||||||
ChatRoomListQueryDto(
|
ChatRoomListQueryDto(
|
||||||
roomId = 20L,
|
roomId = 20L,
|
||||||
@@ -175,5 +187,21 @@ class ChatRoomListServiceTest {
|
|||||||
assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl)
|
assertEquals("https://cdn.test/profile/default-profile.png", response.rooms[0].targetImageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("팔로우 크리에이터 필터는 AI와 DM 저장소에 전달한다")
|
||||||
|
fun shouldPassFollowedCreatorsOnlyToBothRepositories() {
|
||||||
|
val member = member(1L)
|
||||||
|
val pageable = PageRequest.of(0, 11)
|
||||||
|
Mockito.`when`(aiRoomRepository.findAiChatListRooms(1L, null, null, null, true, pageable))
|
||||||
|
.thenReturn(emptyList())
|
||||||
|
Mockito.`when`(dmRoomRepository.findDmChatListRooms(1L, null, null, null, true, pageable))
|
||||||
|
.thenReturn(emptyList())
|
||||||
|
|
||||||
|
service.getRooms(member, filter = "ALL", cursor = null, limit = 10, followedCreatorsOnly = true)
|
||||||
|
|
||||||
|
Mockito.verify(aiRoomRepository).findAiChatListRooms(1L, null, null, null, true, pageable)
|
||||||
|
Mockito.verify(dmRoomRepository).findDmChatListRooms(1L, null, null, null, true, pageable)
|
||||||
|
}
|
||||||
|
|
||||||
private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id }
|
private fun member(id: Long) = Member(password = "pw", nickname = "user").apply { this.id = id }
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-1
@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
|||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
@@ -96,6 +97,38 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(visibleCreator.id, safeBanners.first().creatorId)
|
assertEquals(visibleCreator.id, safeBanners.first().creatorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("콘텐츠 추천 배너는 언어 필터를 limit보다 먼저 적용한다")
|
||||||
|
fun shouldFilterBannersByLangBeforeLimit() {
|
||||||
|
val recommendationTab = saveContentRecommendationTab()
|
||||||
|
val koBanner = saveBanner(
|
||||||
|
"ko-audio-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
1,
|
||||||
|
link = "https://ko.test",
|
||||||
|
tab = recommendationTab,
|
||||||
|
lang = Lang.KO
|
||||||
|
)
|
||||||
|
val jaBanner = saveBanner(
|
||||||
|
"ja-audio-banner.png",
|
||||||
|
AudioContentBannerType.LINK,
|
||||||
|
2,
|
||||||
|
link = "https://ja.test",
|
||||||
|
tab = recommendationTab,
|
||||||
|
lang = Lang.JA
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("https://cdn.test/${koBanner.thumbnailImage}"),
|
||||||
|
repository.findBanners(1, null, false, Lang.KO).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("https://cdn.test/${jaBanner.thumbnailImage}"),
|
||||||
|
repository.findBanners(1, null, false, Lang.JA).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다")
|
@DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다")
|
||||||
fun shouldFindOriginalSeriesWithVisibilityConditions() {
|
fun shouldFindOriginalSeriesWithVisibilityConditions() {
|
||||||
@@ -404,11 +437,13 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
creator: Member? = null,
|
creator: Member? = null,
|
||||||
link: String? = null,
|
link: String? = null,
|
||||||
tab: AudioContentMainTab? = null
|
tab: AudioContentMainTab? = null,
|
||||||
|
lang: Lang = Lang.KO
|
||||||
): AudioContentBanner {
|
): AudioContentBanner {
|
||||||
val banner = AudioContentBanner(
|
val banner = AudioContentBanner(
|
||||||
thumbnailImage = thumbnailImage,
|
thumbnailImage = thumbnailImage,
|
||||||
type = type,
|
type = type,
|
||||||
|
lang = lang,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
orders = orders
|
orders = orders
|
||||||
|
|||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
@@ -77,7 +78,7 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
0,
|
0,
|
||||||
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
|
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
|
||||||
)
|
)
|
||||||
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false)
|
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false, Lang.KO)
|
||||||
Mockito.verify(queryPort).findLatestAudios(
|
Mockito.verify(queryPort).findLatestAudios(
|
||||||
eqValue(AudioRecommendationQueryService.LATEST_AUDIO_LIMIT),
|
eqValue(AudioRecommendationQueryService.LATEST_AUDIO_LIMIT),
|
||||||
Mockito.isNull(),
|
Mockito.isNull(),
|
||||||
@@ -140,6 +141,7 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
fun shouldUseStoredPreferenceForMemberAdultVisibility() {
|
fun shouldUseStoredPreferenceForMemberAdultVisibility() {
|
||||||
val member = member(id = 10L)
|
val member = member(id = 10L)
|
||||||
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
|
||||||
|
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||||
stubFallback(
|
stubFallback(
|
||||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||||
0,
|
0,
|
||||||
@@ -150,6 +152,7 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
service.getRecommendations(member)
|
service.getRecommendations(member)
|
||||||
|
|
||||||
Mockito.verify(preferenceService).canViewAdultContent(member)
|
Mockito.verify(preferenceService).canViewAdultContent(member)
|
||||||
|
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, member.id, true, Lang.JA)
|
||||||
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
|
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
|
||||||
verifyFallback(
|
verifyFallback(
|
||||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||||
|
|||||||
+20
-2
@@ -262,6 +262,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
|
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너는 언어 필터를 limit보다 먼저 적용한다")
|
||||||
|
fun shouldFilterHomeBannersByLangBeforeLimit() {
|
||||||
|
val koBanner = saveBanner("ko-home-banner.png", AudioContentBannerType.LINK, 1, true, lang = Lang.KO)
|
||||||
|
val jaBanner = saveBanner("ja-home-banner.png", AudioContentBannerType.LINK, 2, true, lang = Lang.JA)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(koBanner.thumbnailImage),
|
||||||
|
repository.findHomeBanners(limit = 1, lang = Lang.KO).map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(jaBanner.thumbnailImage),
|
||||||
|
repository.findHomeBanners(limit = 1, lang = Lang.JA).map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다")
|
@DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다")
|
||||||
fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() {
|
fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() {
|
||||||
@@ -2693,12 +2710,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
event: Event? = null,
|
event: Event? = null,
|
||||||
series: Series? = null,
|
series: Series? = null,
|
||||||
tab: AudioContentMainTab? = null,
|
tab: AudioContentMainTab? = null,
|
||||||
link: String? = null
|
link: String? = null,
|
||||||
|
lang: Lang = Lang.KO
|
||||||
): AudioContentBanner {
|
): AudioContentBanner {
|
||||||
val banner = AudioContentBanner(
|
val banner = AudioContentBanner(
|
||||||
thumbnailImage = thumbnailImage,
|
thumbnailImage = thumbnailImage,
|
||||||
type = type,
|
type = type,
|
||||||
lang = Lang.KO,
|
lang = lang,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
orders = orders
|
orders = orders
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
@@ -93,6 +94,16 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
assertEquals(20, port.bannerLimit)
|
assertEquals(20, port.bannerLimit)
|
||||||
assertEquals(100L, port.bannerMemberId)
|
assertEquals(100L, port.bannerMemberId)
|
||||||
assertEquals(true, port.bannerIncludeAdultBanners)
|
assertEquals(true, port.bannerIncludeAdultBanners)
|
||||||
|
assertEquals(Lang.KO, port.bannerLang)
|
||||||
|
assertEquals(port.banners, banners)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 배너 추천은 언어 조건을 조회 포트에 위임한다")
|
||||||
|
fun shouldDelegateHomeBannerLang() {
|
||||||
|
val banners = service.findHomeBanners(lang = Lang.JA)
|
||||||
|
|
||||||
|
assertEquals(Lang.JA, port.bannerLang)
|
||||||
assertEquals(port.banners, banners)
|
assertEquals(port.banners, banners)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,6 +977,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
var bannerLimit: Int? = null
|
var bannerLimit: Int? = null
|
||||||
var bannerMemberId: Long? = null
|
var bannerMemberId: Long? = null
|
||||||
var bannerIncludeAdultBanners: Boolean? = null
|
var bannerIncludeAdultBanners: Boolean? = null
|
||||||
|
var bannerLang: Lang? = null
|
||||||
var activeCreatorLimit: Int? = null
|
var activeCreatorLimit: Int? = null
|
||||||
var activeCreatorMemberId: Long? = null
|
var activeCreatorMemberId: Long? = null
|
||||||
var activeCreatorIncludeAdultActivities: Boolean? = null
|
var activeCreatorIncludeAdultActivities: Boolean? = null
|
||||||
@@ -1068,11 +1080,13 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
override fun findHomeBanners(
|
override fun findHomeBanners(
|
||||||
limit: Int,
|
limit: Int,
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
includeAdultBanners: Boolean
|
includeAdultBanners: Boolean,
|
||||||
|
lang: Lang
|
||||||
): List<HomeBannerRecommendationRecord> {
|
): List<HomeBannerRecommendationRecord> {
|
||||||
bannerLimit = limit
|
bannerLimit = limit
|
||||||
bannerMemberId = memberId
|
bannerMemberId = memberId
|
||||||
bannerIncludeAdultBanners = includeAdultBanners
|
bannerIncludeAdultBanners = includeAdultBanners
|
||||||
|
bannerLang = lang
|
||||||
return banners
|
return banners
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user