15
docs/20260226_오리지널시리즈차단필터적용.md
Normal file
15
docs/20260226_오리지널시리즈차단필터적용.md
Normal 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 빌드 기반으로 검증했다.
|
||||
17
docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md
Normal file
17
docs/20260226_콘텐츠시리즈상세차단오류메시지수정.md
Normal 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` 실행: 성공
|
||||
24
docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md
Normal file
24
docs/20260226_홈콘텐츠랭킹차단크리에이터필터링.md
Normal 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`: 성공.
|
||||
@@ -126,6 +126,7 @@ class HomeService(
|
||||
)
|
||||
|
||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -84,11 +84,12 @@ interface ContentSeriesQueryRepository {
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
locale: String,
|
||||
memberId: Long? = null,
|
||||
offset: Long = 0,
|
||||
limit: Long = 20
|
||||
): 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 findByCurationIdV2(
|
||||
imageHost: String,
|
||||
@@ -715,6 +716,7 @@ class ContentSeriesQueryRepositoryImpl(
|
||||
isAdult: Boolean,
|
||||
contentType: ContentType,
|
||||
locale: String,
|
||||
memberId: Long?,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetSeriesListResponse.SeriesListItem> {
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 "シリーズへのアクセスがブロックされました。"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -80,9 +80,15 @@ class RankingRepository(
|
||||
locale: String? = null
|
||||
): List<GetAudioContentRankingItem> {
|
||||
val blockMemberCondition = if (memberId != null) {
|
||||
blockMember.isActive.isTrue
|
||||
.and(
|
||||
blockMember.member.id.eq(member.id)
|
||||
.and(blockMember.isActive.isTrue)
|
||||
.and(blockMember.blockedMember.id.eq(memberId))
|
||||
.or(
|
||||
blockMember.member.id.eq(memberId)
|
||||
.and(blockMember.blockedMember.id.eq(member.id))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user