test #390

Merged
klaus merged 3 commits from test into main 2026-02-25 16:55:32 +00:00
11 changed files with 142 additions and 9 deletions

View File

@@ -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 빌드 기반으로 검증했다.

View File

@@ -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` 실행: 성공

View File

@@ -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`: 성공.

View File

@@ -126,6 +126,7 @@ class HomeService(
) )
val originalAudioDramaList = seriesService.getOriginalAudioDramaList( val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )

View File

@@ -525,7 +525,7 @@ class AudioContentService(
?: throw SodaException(messageKey = "content.error.user_not_found") ?: throw SodaException(messageKey = "content.error.user_not_found")
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { 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( val creatorFollowing = explorerQueryRepository.getCreatorFollowing(

View File

@@ -42,6 +42,7 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
ApiResponse.ok( ApiResponse.ok(
service.getOriginalAudioDramaList( service.getOriginalAudioDramaList(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true), isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL, contentType = contentType ?: ContentType.ALL,
offset = pageable.offset, offset = pageable.offset,

View File

@@ -41,6 +41,7 @@ class AudioContentMainTabSeriesService(
) )
val originalAudioDrama = seriesService.getOriginalAudioDramaList( val originalAudioDrama = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
offset = 0, offset = 0,
@@ -157,13 +158,15 @@ class AudioContentMainTabSeriesService(
} }
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
memberId: Long,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
limit: Long limit: Long
): GetSeriesListResponse { ): GetSeriesListResponse {
val totalCount = seriesService.getOriginalAudioDramaTotalCount(isAdult, contentType) val totalCount = seriesService.getOriginalAudioDramaTotalCount(memberId, isAdult, contentType)
val items = seriesService.getOriginalAudioDramaList( val items = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
offset = offset, offset = offset,

View File

@@ -84,11 +84,12 @@ interface ContentSeriesQueryRepository {
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
locale: String, locale: String,
memberId: Long? = null,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> ): List<GetSeriesListResponse.SeriesListItem>
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<GetSeriesGenreListResponse> fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List<GetSeriesGenreListResponse>
fun findByCurationIdV2( fun findByCurationIdV2(
imageHost: String, imageHost: String,
@@ -715,6 +716,7 @@ class ContentSeriesQueryRepositoryImpl(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
locale: String, locale: String,
memberId: Long?,
offset: Long, offset: Long,
limit: Long limit: Long
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
@@ -744,6 +746,24 @@ class ContentSeriesQueryRepositoryImpl(
where = where.and(audioContent.isAdult.isFalse) 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 now = LocalDateTime.now()
val sevenDaysAgo = now.minusDays(7) 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 var where = series.isOriginal.isTrue
.and(series.isActive.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 return queryFactory
.select(series.id) .select(series.id)
.from(series) .from(series)

View File

@@ -46,10 +46,27 @@ class ContentSeriesService(
private val coverImageHost: String private val coverImageHost: String
) { ) {
fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int { 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( fun getOriginalAudioDramaList(
memberId: Long?,
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
offset: Long = 0, offset: Long = 0,
@@ -60,6 +77,7 @@ class ContentSeriesService(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
locale = langContext.lang.code, locale = langContext.lang.code,
memberId = memberId,
offset = offset, offset = offset,
limit = limit limit = limit
) )
@@ -229,7 +247,7 @@ class ContentSeriesService(
val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) || val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) ||
blockMemberRepository.isBlocked(blockedMemberId = series.member!!.id!!, memberId = member.id!!) blockMemberRepository.isBlocked(blockedMemberId = series.member!!.id!!, memberId = member.id!!)
if (isBlocked) { if (isBlocked) {
throw SodaException(messageKey = "series.error.invalid_series_retry") throw SodaException(messageKey = "series.error.blocked_access")
} }
val creatorFollowing = explorerQueryRepository.getCreatorFollowing( val creatorFollowing = explorerQueryRepository.getCreatorFollowing(

View File

@@ -103,6 +103,11 @@ class SodaMessageSource {
Lang.EN to "Access to content is restricted at %s's request.", Lang.EN to "Access to content is restricted at %s's request.",
Lang.JA to "%sさんの要請によりコンテンツへのアクセスが制限されています。" 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( "content.error.pin_available_after_open" to mapOf(
Lang.KO to "콘텐츠 오픈 후 채널에 고정이 가능합니다.", Lang.KO to "콘텐츠 오픈 후 채널에 고정이 가능합니다.",
Lang.EN to "You can pin it to the channel after the content is opened.", 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.KO to "잘못된 시리즈 입니다.\n다시 시도해 주세요",
Lang.EN to "Invalid series.\nPlease try again.", Lang.EN to "Invalid series.\nPlease try again.",
Lang.JA to "不正なシリーズです。\nもう一度お試しください。" Lang.JA to "不正なシリーズです。\nもう一度お試しください。"
),
"series.error.blocked_access" to mapOf(
Lang.KO to "시리즈 접근이 차단되었습니다.",
Lang.EN to "Series access is blocked.",
Lang.JA to "シリーズへのアクセスがブロックされました。"
) )
) )

View File

@@ -80,9 +80,15 @@ class RankingRepository(
locale: String? = null locale: String? = null
): List<GetAudioContentRankingItem> { ): List<GetAudioContentRankingItem> {
val blockMemberCondition = if (memberId != null) { val blockMemberCondition = if (memberId != null) {
blockMember.member.id.eq(member.id) blockMember.isActive.isTrue
.and(blockMember.isActive.isTrue) .and(
.and(blockMember.blockedMember.id.eq(memberId)) 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 { } else {
null null
} }