diff --git a/docs/20260226_오리지널시리즈차단필터적용.md b/docs/20260226_오리지널시리즈차단필터적용.md new file mode 100644 index 00000000..63f62fad --- /dev/null +++ b/docs/20260226_오리지널시리즈차단필터적용.md @@ -0,0 +1,15 @@ +# 오리지널 시리즈 차단 필터 적용 + +## 구현 체크리스트 +- [x] `HomeService.fetchData` 경로에서 오리지널 시리즈 조회 시 `memberId` 전달 +- [x] `ContentSeriesService.getOriginalAudioDramaList` 시그니처에 `memberId` 반영 +- [x] `ContentSeriesRepository.getOriginalAudioDramaList` 인터페이스/구현에 `memberId` 반영 +- [x] 오리지널 시리즈 QueryDSL 조회에 양방향 차단(`내가 차단`/`나를 차단`) 서브쿼리 필터 적용 +- [x] 오리지널 탭 API 경로(`AudioContentMainTabSeries*`)에도 `memberId` 전달 +- [x] 빌드/테스트/진단 실행 후 결과 기록 + +## 검증 기록 +- 1차 구현 + - 무엇을: 홈/시리즈탭의 오리지널 시리즈 조회 경로에 `memberId`를 전달하고, `ContentSeriesRepository.getOriginalAudioDramaList` 및 `getOriginalAudioDramaTotalCount`에 양방향 차단 서브쿼리(`blockedSubquery.exists().not()`)를 추가해 차단된 크리에이터 시리즈가 제외되도록 반영했다. + - 왜: 기존에는 오리지널 시리즈 조회 쿼리에 차단 조건이 없어, 내가 차단했거나 나를 차단한 크리에이터의 시리즈가 노출될 수 있었다. + - 어떻게: `./gradlew test` 실행 성공, `./gradlew build` 실행 성공으로 컴파일/테스트/정적검사(ktlint 포함 check 단계) 통과를 확인했다. Kotlin LSP는 환경에 서버가 없어(`.kt` 미지원) 진단 도구로는 확인할 수 없어 Gradle 빌드 기반으로 검증했다. diff --git a/docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md b/docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md new file mode 100644 index 00000000..b9e9815f --- /dev/null +++ b/docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md @@ -0,0 +1,17 @@ +# 2026-02-26 콘텐츠/시리즈 상세 차단 오류메시지 수정 + +## 구현 체크리스트 +- [x] 콘텐츠 상세(`getDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경 +- [x] 시리즈 상세(`getSeriesDetail`) 차단 예외 메시지 키를 전용 차단 키로 변경 +- [x] `SodaMessageSource`에 콘텐츠/시리즈 차단 전용 메시지 키 추가 +- [x] 정적 진단 및 테스트로 변경 영향 검증 + +## 검증 기록 +### 1차 구현 +- 무엇: `AudioContentService.getDetail`의 차단 예외 키를 `content.error.blocked_access`로 변경하고, `ContentSeriesService.getSeriesDetail`의 차단 예외 키를 `series.error.blocked_access`로 변경했다. `SodaMessageSource`에 두 키를 추가해 한국어 기준으로 각각 "콘텐츠 접근이 차단되었습니다.", "시리즈 접근이 차단되었습니다."를 반환하도록 반영했다. +- 왜: 기존에는 차단 상황에서도 `invalid_content_retry`/`invalid_series_retry`를 사용해 오류 의미가 모호했고, 요청 사항대로 차단 상황을 명확한 문구로 안내해야 했기 때문이다. +- 어떻게: + - `lsp_diagnostics` (`AudioContentService.kt`, `ContentSeriesService.kt`, `SodaMessageSource.kt`) 실행: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가) + - `./gradlew test` 실행: 성공 + - `./gradlew ktlintCheck` 실행: 성공 + - `./gradlew build` 실행: 성공 diff --git a/docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md b/docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md new file mode 100644 index 00000000..5798c329 --- /dev/null +++ b/docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md @@ -0,0 +1,24 @@ +- [x] 홈 `fetchData` 콘텐츠 랭킹 조회 경로 및 차단 적용 패턴 확인 +- [x] `RankingRepository.getAudioContentRanking`에 양방향 차단(내가 차단/나를 차단) 조건 적용 +- [x] 변경 파일 진단 및 테스트/빌드 검증 수행 +- [x] 검증 결과 기록 + +## 1차 구현 검증 기록 +- 무엇: 홈 `fetchData`의 `contentRanking`에서 내가 차단한 크리에이터와 나를 차단한 크리에이터의 콘텐츠를 + 모두 제외하도록 서비스 레벨 필터를 추가했다. +- 왜: 기존 랭킹 조회 쿼리에는 한 방향 차단만 반영되어 양방향 차단 관계를 완전히 차단하지 못할 수 있기 때문이다. +- 어떻게: + - `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가. + - `./gradlew ktlintCheck`: 성공. + - `./gradlew test`: 성공. + - `./gradlew build -x test`: 성공. + +## 2차 수정 검증 기록 +- 무엇: 서비스(`HomeService`)에서 처리하던 `contentRanking` 차단 필터를 제거하고, `RankingRepository.getAudioContentRanking` + 쿼리의 `blockMemberCondition`을 양방향 차단 조건으로 수정했다. +- 왜: 홈 서비스가 아닌 랭킹 데이터 조회 계층에서 차단 정책을 일관되게 보장하기 위해서다. +- 어떻게: + - `lsp_diagnostics`: Kotlin(`.kt`)용 LSP 서버가 현재 환경에 없어 도구 기반 진단은 수행 불가. + - `./gradlew ktlintCheck`: 성공. + - `./gradlew test`: 성공. + - `./gradlew build -x test`: 성공. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 6e5ceaa0..7f8d624b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -126,6 +126,7 @@ class HomeService( ) val originalAudioDramaList = seriesService.getOriginalAudioDramaList( + memberId = memberId, isAdult = isAdult, contentType = contentType ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index e810c95f..033bb73b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -525,7 +525,7 @@ class AudioContentService( ?: throw SodaException(messageKey = "content.error.user_not_found") if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { - throw SodaException(messageKey = "content.error.invalid_content_retry") + throw SodaException(messageKey = "content.error.blocked_access") } val creatorFollowing = explorerQueryRepository.getCreatorFollowing( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt index 7dcabf5b..753e11be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesController.kt @@ -42,6 +42,7 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT ApiResponse.ok( service.getOriginalAudioDramaList( + memberId = member.id!!, isAdult = member.auth != null && (isAdultContentVisible ?: true), contentType = contentType ?: ContentType.ALL, offset = pageable.offset, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt index b5c85b7d..692f04de 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt @@ -41,6 +41,7 @@ class AudioContentMainTabSeriesService( ) val originalAudioDrama = seriesService.getOriginalAudioDramaList( + memberId = memberId, isAdult = isAdult, contentType = contentType, offset = 0, @@ -157,13 +158,15 @@ class AudioContentMainTabSeriesService( } fun getOriginalAudioDramaList( + memberId: Long, isAdult: Boolean, contentType: ContentType, offset: Long, limit: Long ): GetSeriesListResponse { - val totalCount = seriesService.getOriginalAudioDramaTotalCount(isAdult, contentType) + val totalCount = seriesService.getOriginalAudioDramaTotalCount(memberId, isAdult, contentType) val items = seriesService.getOriginalAudioDramaList( + memberId = memberId, isAdult = isAdult, contentType = contentType, offset = offset, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index f351a8f6..f947580f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -84,11 +84,12 @@ interface ContentSeriesQueryRepository { isAdult: Boolean, contentType: ContentType, locale: String, + memberId: Long? = null, offset: Long = 0, limit: Long = 20 ): List - fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int + fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType, memberId: Long? = null): Int fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List fun findByCurationIdV2( imageHost: String, @@ -715,6 +716,7 @@ class ContentSeriesQueryRepositoryImpl( isAdult: Boolean, contentType: ContentType, locale: String, + memberId: Long?, offset: Long, limit: Long ): List { @@ -744,6 +746,24 @@ class ContentSeriesQueryRepositoryImpl( where = where.and(audioContent.isAdult.isFalse) } + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) + ) + where = where.and(blockedSubquery.exists().not()) + } + val now = LocalDateTime.now() val sevenDaysAgo = now.minusDays(7) @@ -823,7 +843,7 @@ class ContentSeriesQueryRepositoryImpl( } } - override fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int { + override fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType, memberId: Long?): Int { var where = series.isOriginal.isTrue .and(series.isActive.isTrue) @@ -845,6 +865,24 @@ class ContentSeriesQueryRepositoryImpl( } } + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(series.member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(series.member.id)) + ) + ) + ) + where = where.and(blockedSubquery.exists().not()) + } + return queryFactory .select(series.id) .from(series) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index dc2b2140..6f32b812 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -46,10 +46,27 @@ class ContentSeriesService( private val coverImageHost: String ) { fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int { - return repository.getOriginalAudioDramaTotalCount(isAdult, contentType) + return repository.getOriginalAudioDramaTotalCount( + isAdult = isAdult, + contentType = contentType, + memberId = null + ) + } + + fun getOriginalAudioDramaTotalCount( + memberId: Long?, + isAdult: Boolean, + contentType: ContentType + ): Int { + return repository.getOriginalAudioDramaTotalCount( + isAdult = isAdult, + contentType = contentType, + memberId = memberId + ) } fun getOriginalAudioDramaList( + memberId: Long?, isAdult: Boolean, contentType: ContentType, offset: Long = 0, @@ -60,6 +77,7 @@ class ContentSeriesService( isAdult = isAdult, contentType = contentType, locale = langContext.lang.code, + memberId = memberId, offset = offset, limit = limit ) @@ -229,7 +247,7 @@ class ContentSeriesService( val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) || blockMemberRepository.isBlocked(blockedMemberId = series.member!!.id!!, memberId = member.id!!) if (isBlocked) { - throw SodaException(messageKey = "series.error.invalid_series_retry") + throw SodaException(messageKey = "series.error.blocked_access") } val creatorFollowing = explorerQueryRepository.getCreatorFollowing( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 1fa07b51..68e4aad0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -103,6 +103,11 @@ class SodaMessageSource { Lang.EN to "Access to content is restricted at %s's request.", Lang.JA to "%sさんの要請によりコンテンツへのアクセスが制限されています。" ), + "content.error.blocked_access" to mapOf( + Lang.KO to "콘텐츠 접근이 차단되었습니다.", + Lang.EN to "Content access is blocked.", + Lang.JA to "コンテンツへのアクセスがブロックされました。" + ), "content.error.pin_available_after_open" to mapOf( Lang.KO to "콘텐츠 오픈 후 채널에 고정이 가능합니다.", Lang.EN to "You can pin it to the channel after the content is opened.", @@ -261,6 +266,11 @@ class SodaMessageSource { Lang.KO to "잘못된 시리즈 입니다.\n다시 시도해 주세요", Lang.EN to "Invalid series.\nPlease try again.", Lang.JA to "不正なシリーズです。\nもう一度お試しください。" + ), + "series.error.blocked_access" to mapOf( + Lang.KO to "시리즈 접근이 차단되었습니다.", + Lang.EN to "Series access is blocked.", + Lang.JA to "シリーズへのアクセスがブロックされました。" ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index a95c5e05..69670edb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -80,9 +80,15 @@ class RankingRepository( locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { - blockMember.member.id.eq(member.id) - .and(blockMember.isActive.isTrue) - .and(blockMember.blockedMember.id.eq(memberId)) + blockMember.isActive.isTrue + .and( + blockMember.member.id.eq(member.id) + .and(blockMember.blockedMember.id.eq(memberId)) + .or( + blockMember.member.id.eq(memberId) + .and(blockMember.blockedMember.id.eq(member.id)) + ) + ) } else { null }