feat(content-preference): 콘텐츠 조회 설정 서버 저장 전환을 반영한다

This commit is contained in:
2026-03-27 13:33:51 +09:00
parent 1ba3cb8a40
commit a87bd147dc
75 changed files with 3593 additions and 301 deletions

View File

@@ -24,8 +24,8 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.fetchData(
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member
)
)
@@ -41,8 +41,8 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.getLatestContentByTheme(
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member
)
)
@@ -58,8 +58,8 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.getDayOfWeekSeriesList(
dayOfWeek = dayOfWeek,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member
)
)
@@ -74,8 +74,8 @@ class HomeController(private val service: HomeService) {
) = run {
ApiResponse.ok(
service.getRecommendContentList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member = member
)
)
@@ -95,8 +95,8 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.getContentRankingBySort(
sort = sort ?: ContentRankingSortType.REVENUE,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
offset = offset,
limit = limit,
theme = theme,

View File

@@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository
@@ -47,6 +49,7 @@ class HomeService(
private val explorerQueryRepository: ExplorerQueryRepository,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -69,17 +72,19 @@ class HomeService(
fun fetchData(
timezone: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
member: Member?
): GetHomeResponse {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val resolvedContentType = preference.contentType
val liveList = liveRoomService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
isAdultContentVisible = isAdult,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -102,14 +107,14 @@ class HomeService(
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
excludeThemes = listOf("다시듣기")
)
val latestContentList = contentService.getLatestContentByTheme(
memberId = memberId,
theme = latestContentThemeList,
contentType = contentType,
contentType = resolvedContentType,
isFree = false,
isAdult = isAdult
)
@@ -128,7 +133,7 @@ class HomeService(
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
contentType = resolvedContentType
)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
@@ -137,7 +142,7 @@ class HomeService(
val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
@@ -157,7 +162,7 @@ class HomeService(
val contentRanking = rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
startDate = startDate.minusDays(1),
endDate = endDate,
sort = ContentRankingSortType.REVENUE
@@ -166,17 +171,17 @@ class HomeService(
val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
contentType = resolvedContentType
)
val freeContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = true,
contentType = contentType
contentType = resolvedContentType
),
isFree = true,
isPointAvailableOnly = false
@@ -186,7 +191,7 @@ class HomeService(
val pointAvailableContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = resolvedContentType,
theme = emptyList(),
isFree = false,
isPointAvailableOnly = true
@@ -212,9 +217,8 @@ class HomeService(
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
pointAvailableContentList = pointAvailableContentList,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
recommendContentList = getRecommendContentListByPreference(
preference = preference,
member = member,
excludeContentIds = excludeContentIds
)
@@ -223,18 +227,20 @@ class HomeService(
fun getLatestContentByTheme(
theme: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
member: Member?
): List<AudioContentMainItem> {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val resolvedContentType = preference.contentType
val themeList = if (theme.isBlank()) {
contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
isFree = false,
contentType = contentType,
contentType = resolvedContentType,
excludeThemes = listOf("다시듣기")
)
} else {
@@ -244,7 +250,7 @@ class HomeService(
return contentService.getLatestContentByTheme(
memberId = memberId,
theme = themeList,
contentType = contentType,
contentType = resolvedContentType,
isFree = false,
isAdult = isAdult
)
@@ -252,32 +258,34 @@ class HomeService(
fun getDayOfWeekSeriesList(
dayOfWeek: SeriesPublishedDaysOfWeek,
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
member: Member?
): List<GetSeriesListResponse.SeriesListItem> {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
return seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = preference.contentType,
dayOfWeek = dayOfWeek
)
}
fun getContentRankingBySort(
sort: ContentRankingSortType,
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
offset: Long?,
limit: Long?,
theme: String?,
member: Member?
): List<GetAudioContentRankingItem> {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
@@ -291,7 +299,7 @@ class HomeService(
return rankingService.getContentRanking(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
contentType = preference.contentType,
startDate = startDate.minusDays(1),
endDate = endDate,
offset = offset ?: 0,
@@ -320,13 +328,22 @@ class HomeService(
}
fun getRecommendContentList(
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
member: Member?,
excludeContentIds: List<Long> = emptyList()
): List<AudioContentMainItem> {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
return getRecommendContentListByPreference(preference, member, excludeContentIds)
}
private fun getRecommendContentListByPreference(
preference: ViewerContentPreference,
member: Member?,
excludeContentIds: List<Long>
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
// 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다.
val buckets = listOf(
@@ -350,7 +367,7 @@ class HomeService(
val batch = contentService.getLatestContentByTheme(
memberId = memberId,
theme = emptyList(),
contentType = contentType,
contentType = preference.contentType,
offset = bucket.offset,
limit = bucket.limit,
sortType = SortType.NEWEST,
@@ -374,6 +391,27 @@ class HomeService(
return result.take(RECOMMEND_TARGET_SIZE).shuffled()
}
private fun resolvePreference(
member: Member?,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdultContentVisible ?: false,
contentType = contentType ?: ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}
private fun pickByTimeDecay(
batch: List<AudioContentMainItem>,
targetSize: Int,

View File

@@ -23,8 +23,8 @@ class LiveApiController(
) = run {
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
timezone = timezone,
member = member
)

View File

@@ -8,6 +8,8 @@ import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -17,22 +19,24 @@ class LiveApiService(
private val contentService: AudioContentService,
private val recommendService: LiveRecommendService,
private val creatorCommunityService: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockMemberRepository: BlockMemberRepository
) {
fun fetchData(
isAdultContentVisible: Boolean,
contentType: ContentType,
isAdultContentVisible: Boolean?,
contentType: ContentType?,
timezone: String,
member: Member?
): LiveMainResponse {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = preference.isAdult
val liveOnAirRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.NOW,
isAdultContentVisible = isAdultContentVisible,
isAdultContentVisible = isAdult,
pageable = Pageable.ofSize(20),
member = member,
timezone = timezone
@@ -55,7 +59,7 @@ class LiveApiService(
val replayLive = contentService.getLatestContentByTheme(
memberId = memberId,
theme = listOf("다시듣기"),
contentType = contentType,
contentType = preference.contentType,
isFree = false,
isAdult = isAdult
)
@@ -77,7 +81,7 @@ class LiveApiService(
val liveReservationRoomList = liveService.getRoomList(
dateString = null,
status = LiveRoomStatus.RESERVATION,
isAdultContentVisible = isAdultContentVisible,
isAdultContentVisible = isAdult,
pageable = Pageable.ofSize(10),
member = member,
timezone = timezone
@@ -93,4 +97,25 @@ class LiveApiService(
liveReservationRoomList = liveReservationRoomList
)
}
private fun resolvePreference(
member: Member?,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdultContentVisible ?: false,
contentType = contentType ?: ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}
}

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
@@ -22,6 +23,7 @@ class CharacterCommentController(
private val service: CharacterCommentService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -33,7 +35,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addComment(characterId, member, request.comment)
@@ -48,7 +50,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
@@ -63,7 +65,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
val data = service.listComments(imageHost, characterId, cursor, limit)
ApiResponse.ok(data)
@@ -78,7 +80,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
val data = service.getReplies(imageHost, commentId, cursor, limit)
@@ -92,7 +94,7 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
service.deleteComment(characterId, commentId, member)
val message = messageSource.getMessage("chat.character.comment.deleted", langContext.lang)
ApiResponse.ok(true, message)
@@ -106,9 +108,15 @@ class CharacterCommentController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
service.reportComment(characterId, commentId, member, request.content)
val message = messageSource.getMessage("chat.character.comment.reported", langContext.lang)
ApiResponse.ok(true, message)
}
private fun validateAdultAccess(member: Member) {
if (!memberContentPreferenceService.getStoredPreference(member).isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
}
}

View File

@@ -27,6 +27,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -49,6 +50,7 @@ class ChatCharacterController(
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -57,6 +59,8 @@ class ChatCharacterController(
fun getCharacterMain(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<CharacterMainResponse> = run {
val isAdultAccessible = resolveIsAdultAccessible(member)
// 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
@@ -68,7 +72,7 @@ class ChatCharacterController(
}
// 최근 대화한 캐릭터(채팅방) 조회 (회원별 최근 순으로 최대 10개)
val recentCharacters = if (member == null || member.auth == null) {
val recentCharacters = if (member == null || !isAdultAccessible) {
emptyList()
} else {
chatRoomService.listMyChatRooms(member, 0, 10)
@@ -156,7 +160,7 @@ class ChatCharacterController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (!resolveIsAdultAccessible(member)) throw SodaException(messageKey = "common.error.adult_verification_required")
// 캐릭터 상세 정보 조회
val character = service.getCharacterDetail(characterId)
@@ -396,7 +400,8 @@ class ChatCharacterController(
fun getRecommendCharacters(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val recent = if (member == null || member.auth == null) {
val isAdultAccessible = resolveIsAdultAccessible(member)
val recent = if (member == null || !isAdultAccessible) {
emptyList()
} else {
chatRoomService
@@ -447,4 +452,12 @@ class ChatCharacterController(
aiCharacterList
}
}
private fun resolveIsAdultAccessible(member: Member?): Boolean {
if (member == null) {
return false
}
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseR
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -25,6 +26,7 @@ class CharacterImageController(
private val imageService: CharacterImageService,
private val imageCloudFront: ImageContentCloudFront,
private val canPaymentService: CanPaymentService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -37,7 +39,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
val pageSize = if (size <= 0) 20 else minOf(size, 20)
@@ -125,7 +127,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
val pageSize = if (size <= 0) 20 else minOf(size, 20)
val expiration = 5L * 60L * 1000L // 5분
@@ -199,7 +201,7 @@ class CharacterImageController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
validateAdultAccess(member)
val image = imageService.getById(req.imageId)
if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive")
@@ -223,4 +225,10 @@ class CharacterImageController(
val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration)
ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl))
}
private fun validateAdultAccess(member: Member) {
if (!memberContentPreferenceService.getStoredPreference(member).isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
}
}

View File

@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.like.PutAudioContentLikeRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.data.domain.Pageable
import org.springframework.lang.Nullable
import org.springframework.security.access.prepost.PreAuthorize
@@ -25,7 +27,10 @@ import java.time.temporal.TemporalAdjusters
@RestController
@RequestMapping("/audio-content")
class AudioContentController(private val service: AudioContentService) {
class AudioContentController(
private val service: AudioContentService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@PostMapping
@PreAuthorize("hasRole('CREATOR')")
fun createAudioContent(
@@ -112,14 +117,15 @@ class AudioContentController(private val service: AudioContentService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getAudioContentList(
creatorId = creatorId,
sortType = sortType ?: SortType.NEWEST,
categoryId = categoryId ?: 0,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -135,12 +141,13 @@ class AudioContentController(private val service: AudioContentService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, null)
ApiResponse.ok(
service.getDetail(
id = id,
member = member,
isAdultContentVisible = isAdultContentVisible ?: true,
isAdultContentVisible = preference.isAdultContentVisible,
timezone = timezone
)
)
@@ -192,6 +199,7 @@ class AudioContentController(private val service: AudioContentService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
@@ -204,8 +212,8 @@ class AudioContentController(private val service: AudioContentService) {
ApiResponse.ok(
service.getAudioContentRanking(
isAdult = member?.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
startDate = startDate,
endDate = endDate,
offset = pageable.offset,
@@ -249,17 +257,18 @@ class AudioContentController(private val service: AudioContentService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getLatestContentByTheme(
memberId = member.id!!,
theme = if (theme == null) listOf() else listOf(theme),
contentType = contentType ?: ContentType.ALL,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
sortType = sortType ?: SortType.NEWEST,
isFree = isFree ?: false,
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
isAdult = preference.isAdult,
isPointAvailableOnly = isPointAvailableOnly ?: false
)
)
@@ -271,18 +280,36 @@ class AudioContentController(private val service: AudioContentService) {
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getLatestContentByTheme(
memberId = member?.id,
theme = listOf("다시듣기"),
contentType = contentType ?: ContentType.ALL,
contentType = preference.contentType,
isFree = false,
isAdult = if (member != null) {
(isAdultContentVisible ?: true) && member.auth != null
} else {
false
}
isAdult = preference.isAdult
)
)
}
private fun resolvePreference(
member: Member?,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdultContentVisible ?: false,
contentType = contentType ?: ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}
}

View File

@@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
@@ -527,12 +528,16 @@ class AudioContentService(
isAdultContentVisible: Boolean,
timezone: String
): GetAudioContentDetailResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
val audioContent = repository.findByIdOrNull(id)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
if (audioContent.isAdult && !isAdult) {
throw SodaException(messageKey = "common.error.adult_verification_required")
}
// 크리에이터(유저) 정보
val creatorId = audioContent.member!!.id!!
val creator = explorerQueryRepository.getMember(creatorId)
@@ -670,14 +675,16 @@ class AudioContentService(
cloudfrontHost = coverImageHost,
contentId = audioContent.id!!,
creatorId = creatorId,
isAdult = member.auth != null
// 관련 콘텐츠 노출도 동일하게 저장 선호 기반 성인 정책을 따른다.
isAdult = isAdult
)
val sameThemeOtherContentList = repository.getSameThemeOtherContentList(
cloudfrontHost = coverImageHost,
contentId = audioContent.id!!,
themeId = audioContent.theme!!.id!!,
isAdult = member.auth != null
// 동일 테마 추천도 메인 상세와 동일한 성인 정책으로 정렬한다.
isAdult = isAdult
)
val likeCount = audioContentLikeRepository.totalCountAudioContentLike(contentId = id)
@@ -864,7 +871,8 @@ class AudioContentService(
orderSequence = orderSequence,
isActivePreview = audioContent.isGeneratePreview,
isAdult = audioContent.isAdult,
isMosaic = audioContent.isAdult && member.auth == null,
// 성인 콘텐츠이면서 현재 조회 정책으로 열람 불가한 경우에만 모자이크를 적용한다.
isMosaic = audioContent.isAdult && !isAdult,
isOnlyRental = isOnlyRental,
existOrdered = isExistsAudioContent,
purchaseOption = purchaseOption,
@@ -904,7 +912,7 @@ class AudioContentService(
member: Member,
isAdultContentVisible: Boolean
): GetAudioContentListItem? {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
return null
@@ -978,7 +986,7 @@ class AudioContentService(
offset: Long,
limit: Long
): GetAudioContentListResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val isCreator = member.id == creatorId
if (!isCreator && isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.order.OrderService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -16,18 +17,20 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/audio-content/main")
class AudioContentMainController(
private val service: AudioContentMainService,
private val orderService: OrderService
private val orderService: OrderService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping("/new-content-upload-creator")
fun newContentUploadCreatorList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, null, null)
ApiResponse.ok(
service.getNewContentUploadCreatorList(
memberId = member.id!!,
isAdult = member.auth != null
isAdult = preference.isAdult
)
)
}
@@ -37,11 +40,12 @@ class AudioContentMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, null, null)
ApiResponse.ok(
service.getAudioContentMainBannerList(
memberId = member.id!!,
isAdult = member.auth != null
isAdult = preference.isAdult
)
)
}
@@ -69,12 +73,13 @@ class AudioContentMainController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member,
pageable
)
@@ -88,11 +93,12 @@ class AudioContentMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getThemeList(
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
@@ -106,12 +112,13 @@ class AudioContentMainController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getNewContentFor2WeeksByTheme(
theme = theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
pageable = pageable
)
@@ -126,15 +133,26 @@ class AudioContentMainController(
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getAudioContentCurationListWithPaging(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.domain.Pageable
@@ -68,7 +69,7 @@ class AudioContentMainService(
} else {
emptyList()
},
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -87,7 +88,7 @@ class AudioContentMainService(
* - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val themeListRaw = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult,

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -15,7 +16,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/curation")
class AudioContentCurationController(private val service: AudioContentCurationService) {
class AudioContentCurationController(
private val service: AudioContentCurationService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping("/{id}")
fun getCurationContent(
@PathVariable id: Long,
@@ -26,16 +30,27 @@ class AudioContentCurationController(private val service: AudioContentCurationSe
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getCurationContent(
curationId = id,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
sortType = sortType ?: SortType.NEWEST,
member = member,
pageable = pageable
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -30,20 +31,19 @@ class AudioContentCurationService(
): GetCurationContentResponse {
val totalCount = repository.findTotalCountByCurationId(
curationId = curationId,
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType
)
val audioContentList = repository.findByCurationId(
curationId = curationId,
cloudfrontHost = cloudFrontHost,
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType,
sortType = sortType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
.filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) }
).filter { !isBlockedBetweenMembers(memberId = member.id!!, creatorId = it.creatorId) }
return GetCurationContentResponse(
totalCount = totalCount,

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/alarm")
class AudioContentMainTabAlarmController(private val service: AudioContentMainTabAlarmService) {
class AudioContentMainTabAlarmController(
private val service: AudioContentMainTabAlarmService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainTabAlarm(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -21,11 +25,12 @@ class AudioContentMainTabAlarmController(private val service: AudioContentMainTa
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -40,16 +45,27 @@ class AudioContentMainTabAlarmController(private val service: AudioContentMainTa
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchAlarmContentByTheme(
theme,
member,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationQueryR
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -27,7 +28,7 @@ class AudioContentMainTabAlarmService(
contentType: ContentType,
member: Member
): GetContentMainTabAlarmResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
val contentBannerList = bannerService.getBannerList(
@@ -105,7 +106,7 @@ class AudioContentMainTabAlarmService(
}
val memberId = member.id!!
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val totalCount = contentRepository.totalAlarmCountByTheme(
memberId = memberId,

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -12,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/asmr")
class AudioContentMainTabAsmrController(private val service: AudioContentMainTabAsmrService) {
class AudioContentMainTabAsmrController(
private val service: AudioContentMainTabAsmrService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainTabAsmr(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -20,11 +24,12 @@ class AudioContentMainTabAsmrController(private val service: AudioContentMainTab
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -38,13 +43,24 @@ class AudioContentMainTabAsmrController(private val service: AudioContentMainTab
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -26,7 +27,7 @@ class AudioContentMainTabAsmrService(
contentType: ContentType,
member: Member
): GetContentMainTabAsmrResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
val theme = "ASMR"
val tabId = 5L

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/content")
class AudioContentMainTabContentController(private val service: AudioContentMainTabContentService) {
class AudioContentMainTabContentController(
private val service: AudioContentMainTabContentService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainTabContent(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -21,11 +25,12 @@ class AudioContentMainTabContentController(private val service: AudioContentMain
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -40,12 +45,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getAudioContentRanking(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
sortType = sortType ?: "매출"
)
)
@@ -60,12 +66,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -79,12 +86,13 @@ class AudioContentMainTabContentController(private val service: AudioContentMain
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
@@ -92,16 +100,29 @@ class AudioContentMainTabContentController(private val service: AudioContentMain
@GetMapping("/recommend-content-by-tag")
fun getRecommendedContentByTag(
@RequestParam tag: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getRecommendedContentByTag(
memberId = member.id!!,
tag = tag,
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@@ -30,7 +31,7 @@ class AudioContentMainTabContentService(
member: Member
): GetContentMainTabContentResponse {
val memberId = member.id!!
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val tabId = 3L
// 단편 배너
@@ -114,6 +115,7 @@ class AudioContentMainTabContentService(
tagCurationService.getTagCurationContentList(
memberId = memberId,
tag = tagList[0],
isAdult = isAdult,
contentType = contentType
)
} else {
@@ -189,7 +191,7 @@ class AudioContentMainTabContentService(
contentType: ContentType,
member: Member
): List<GetAudioContentMainItem> {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val themeList = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
@@ -232,8 +234,14 @@ class AudioContentMainTabContentService(
fun getRecommendedContentByTag(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
return tagCurationService.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType)
return tagCurationService.getTagCurationContentList(
memberId = memberId,
tag = tag,
isAdult = isAdult,
contentType = contentType
)
}
}

View File

@@ -27,7 +27,9 @@ class ContentMainTabTagCurationRepository(
.and(contentHashTagCurationItem.isActive.isTrue)
if (!isAdult) {
// 큐레이션 메타와 실제 콘텐츠 양쪽에서 성인 항목을 함께 차단한다.
where = where.and(contentHashTagCuration.isAdult.isFalse)
.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(
@@ -60,6 +62,7 @@ class ContentMainTabTagCurationRepository(
fun getTagCurationContentList(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
val blockMemberCondition = blockMember.isActive.isTrue
@@ -79,6 +82,11 @@ class ContentMainTabTagCurationRepository(
.and(contentHashTagCurationItem.isActive.isTrue)
.and(contentHashTagCuration.tag.eq(tag))
if (!isAdult) {
// 추천 태그 콘텐츠 조회에서도 실제 오디오 콘텐츠 성인 노출을 동일 정책으로 제한한다.
where = where.and(audioContent.isAdult.isFalse)
}
if (contentType != ContentType.ALL) {
where = where.and(
audioContent.member.isNull.or(

View File

@@ -13,8 +13,14 @@ class ContentMainTabTagCurationService(private val repository: ContentMainTabTag
fun getTagCurationContentList(
memberId: Long,
tag: String,
isAdult: Boolean,
contentType: ContentType
): List<GetAudioContentMainItem> {
return repository.getTagCurationContentList(memberId = memberId, tag = tag, contentType = contentType)
return repository.getTagCurationContentList(
memberId = memberId,
tag = tag,
isAdult = isAdult,
contentType = contentType
)
}
}

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/free")
class AudioContentMainTabFreeController(private val service: AudioContentMainTabFreeService) {
class AudioContentMainTabFreeController(
private val service: AudioContentMainTabFreeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainFree(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -21,11 +25,12 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -39,12 +44,13 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getIntroduceCreator(
member,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -60,12 +66,13 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getNewContentByTheme(
theme,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -81,13 +88,24 @@ class AudioContentMainTabFreeController(private val service: AudioContentMainTab
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.content.main.tab.RecommendSeriesRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -30,7 +31,7 @@ class AudioContentMainTabFreeService(
contentType: ContentType,
member: Member
): GetContentMainTabFreeResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
val tabId = 7L
@@ -134,7 +135,7 @@ class AudioContentMainTabFreeService(
offset: Long,
limit: Long
): List<GetAudioContentMainItem> {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
val introduceCreatorCuration = curationRepository.findByContentMainTabIdAndTitle(
@@ -171,7 +172,7 @@ class AudioContentMainTabFreeService(
listOf(theme)
} else {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
isFree = true,
contentType = contentType
).filter {
@@ -185,7 +186,7 @@ class AudioContentMainTabFreeService(
it != "자기소개"
}
},
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType,
offset = offset,
limit = limit,

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.content.main.tab.home
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -11,17 +13,21 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/home")
class AudioContentMainTabHomeController(private val service: AudioContentMainTabHomeService) {
class AudioContentMainTabHomeController(
private val service: AudioContentMainTabHomeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainHome(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -34,11 +40,12 @@ class AudioContentMainTabHomeController(private val service: AudioContentMainTab
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = member?.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
@@ -50,13 +57,35 @@ class AudioContentMainTabHomeController(private val service: AudioContentMainTab
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getContentRanking(
sortType = sortType ?: "매출",
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
}
private fun resolvePreference(
member: Member?,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdultContentVisible ?: false,
contentType = contentType ?: ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}
}

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.notice.ServiceNoticeService
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -42,7 +43,7 @@ class AudioContentMainTabHomeService(
val formattedLastMonday = startDate.format(startDateFormatter)
val formattedLastSunday = endDate.format(endDateFormatter)
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
// 최근 공지사항
val latestNotice = noticeService.getLatestNotice()
@@ -130,7 +131,7 @@ class AudioContentMainTabHomeService(
contentType: ContentType,
member: Member?
): List<GetAudioContentRankingItem> {
val isAdult = member?.auth != null && isAdultContentVisible
val isAdult = member?.let { isAdultVisibleByPolicy(it, isAdultContentVisible) } ?: false
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
@@ -12,7 +13,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/replay")
class AudioContentMainTabLiveReplayController(private val service: AudioContentMainTabLiveReplayService) {
class AudioContentMainTabLiveReplayController(
private val service: AudioContentMainTabLiveReplayService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainTabLiveReplay(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -20,11 +24,12 @@ class AudioContentMainTabLiveReplayController(private val service: AudioContentM
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -38,13 +43,24 @@ class AudioContentMainTabLiveReplayController(private val service: AudioContentM
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getPopularContentByCreator(
creatorId = creatorId,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTabRepository
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
@@ -26,7 +27,7 @@ class AudioContentMainTabLiveReplayService(
contentType: ContentType,
member: Member
): GetContentMainTabLiveReplayResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
val theme = "다시듣기"
val tabId = 6L

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v2/audio-content/main/series")
class AudioContentMainTabSeriesController(private val service: AudioContentMainTabSeriesService) {
class AudioContentMainTabSeriesController(
private val service: AudioContentMainTabSeriesService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun fetchContentMainSeries(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@@ -21,11 +25,12 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.fetchData(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member
)
)
@@ -39,12 +44,13 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getOriginalAudioDramaList(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -59,12 +65,13 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
pageable: Pageable
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getRank10DaysCompletedSeriesList(
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
@@ -79,13 +86,14 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getRecommendSeriesListByGenre(
genreId,
memberId = member.id!!,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
@@ -98,13 +106,24 @@ class AudioContentMainTabSeriesController(private val service: AudioContentMainT
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getRecommendSeriesByCreator(
creatorId = creatorId,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL
isAdult = preference.isAdult,
contentType = preference.contentType
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.EventService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.rank.RankingService
import org.springframework.stereotype.Service
import java.time.DayOfWeek
@@ -30,7 +31,7 @@ class AudioContentMainTabSeriesService(
contentType: ContentType,
member: Member
): GetContentMainTabSeriesResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val memberId = member.id!!
// 메인 배너 (시리즈)

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -15,7 +16,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/series")
class ContentSeriesController(private val service: ContentSeriesService) {
class ContentSeriesController(
private val service: ContentSeriesService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun getSeriesList(
@RequestParam(required = false) creatorId: Long?,
@@ -27,14 +31,15 @@ class ContentSeriesController(private val service: ContentSeriesService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getSeriesList(
creatorId = creatorId,
isOriginal = isOriginal ?: false,
isCompleted = isCompleted ?: false,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -50,12 +55,13 @@ class ContentSeriesController(private val service: ContentSeriesService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getSeriesDetail(
seriesId = id,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
)
@@ -71,12 +77,13 @@ class ContentSeriesController(private val service: ContentSeriesService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getSeriesContentList(
seriesId = id,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
sortType = sortType ?: SeriesSortType.NEWEST,
offset = pageable.offset,
@@ -92,13 +99,24 @@ class ContentSeriesController(private val service: ContentSeriesService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getRecommendSeriesList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -918,8 +918,10 @@ class ContentSeriesQueryRepositoryImpl(
.and(blockMember.id.isNull)
if (!isAdult) {
// 비성인 조회에서는 장르/시리즈/콘텐츠 3계층 모두에서 성인 항목을 제외한다.
where = where.and(seriesGenre.isAdult.isFalse)
.and(series.isAdult.isFalse)
.and(audioContent.isAdult.isFalse)
} else {
if (contentType != ContentType.ALL) {
where = where.and(

View File

@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -168,7 +169,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val totalCount = repository.getSeriesTotalCount(
creatorId = creatorId,
@@ -206,7 +207,7 @@ class ContentSeriesService(
offset: Long = 0,
limit: Long = 20
): GetSeriesListResponse {
val isAuth = member.auth != null && isAdultContentVisible
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
val totalCount = repository.getSeriesByGenreTotalCount(
genreId = genreId,
@@ -240,7 +241,7 @@ class ContentSeriesService(
): GetSeriesDetailResponse {
val series = repository.getSeriesDetail(
seriesId = seriesId,
isAuth = member.auth != null && isAdultContentVisible,
isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType
) ?: throw SodaException(messageKey = "series.error.invalid_series_retry")
@@ -428,7 +429,7 @@ class ContentSeriesService(
offset: Long,
limit: Long
): GetSeriesContentListResponse {
val isAdult = member.auth != null && isAdultContentVisible
val isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible)
val totalCount = seriesContentRepository.getContentCount(seriesId, isAdult = isAdult, contentType = contentType)
val contentList = seriesContentRepository.getContentList(
@@ -491,7 +492,7 @@ class ContentSeriesService(
contentType: ContentType,
member: Member
): List<GetSeriesListResponse.SeriesListItem> {
val isAuth = member.auth != null && isAdultContentVisible
val isAuth = isAdultVisibleByPolicy(member, isAdultContentVisible)
return repository.getRecommendSeriesListV2(
imageHost = coverImageHost,
isAuth = isAuth,

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController
class SeriesMainController(
private val contentSeriesService: ContentSeriesService,
private val bannerService: ContentSeriesBannerService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
@@ -32,6 +34,7 @@ class SeriesMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
.content
@@ -43,14 +46,14 @@ class SeriesMainController(
creatorId = null,
isCompleted = true,
orderByRandom = true,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
).items
val recommendSeriesList = contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
@@ -71,11 +74,12 @@ class SeriesMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
contentSeriesService.getRecommendSeriesList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
)
@@ -91,13 +95,14 @@ class SeriesMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getDayOfWeekSeriesList(
memberId = member.id,
isAdult = member.auth != null && (isAdultContentVisible ?: true),
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
dayOfWeek = dayOfWeek,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -112,15 +117,16 @@ class SeriesMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val memberId = member.id!!
val isAdult = member.auth != null && (isAdultContentVisible ?: true)
val isAdult = preference.isAdult
ApiResponse.ok(
contentSeriesService.getGenreList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType ?: ContentType.ALL
contentType = preference.contentType
)
)
}
@@ -135,17 +141,28 @@ class SeriesMainController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
val pageable = PageRequest.of(page, size)
ApiResponse.ok(
contentSeriesService.getSeriesListByGenre(
genreId = genreId,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -16,7 +17,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/audio-content/theme")
class AudioContentThemeController(private val service: AudioContentThemeService) {
class AudioContentThemeController(
private val service: AudioContentThemeService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
@PreAuthorize("hasRole('CREATOR')")
fun getThemes(
@@ -36,13 +40,14 @@ class AudioContentThemeController(private val service: AudioContentThemeService)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getActiveThemeOfContent(
isAdult = member.auth != null && (isAdultContentVisible ?: true),
isAdult = preference.isAdult,
isFree = isFree ?: false,
isPointAvailableOnly = isPointAvailableOnly ?: false,
contentType = contentType ?: ContentType.ALL
contentType = preference.contentType
)
)
}
@@ -57,17 +62,28 @@ class AudioContentThemeController(private val service: AudioContentThemeService)
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.getContentByTheme(
themeId = id,
sortType = sortType ?: SortType.NEWEST,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -129,7 +130,7 @@ class AudioContentThemeService(
val totalCount = contentRepository.totalCountByTheme(
memberId = member.id!!,
theme = listOf(theme.theme),
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType
)
@@ -137,7 +138,7 @@ class AudioContentThemeService(
memberId = member.id!!,
theme = listOf(theme.theme),
sortType = sortType,
isAdult = member.auth != null && isAdultContentVisible,
isAdult = isAdultVisibleByPolicy(member, isAdultContentVisible),
contentType = contentType,
offset = offset,
limit = limit

View File

@@ -67,7 +67,7 @@ class ExplorerController(
service.getCreatorProfile(
creatorId = creatorId,
timezone = timezone,
isAdultContentVisible = isAdultContentVisible ?: true,
isAdultContentVisible = isAdultContentVisible,
member = member
)
)

View File

@@ -339,6 +339,7 @@ class ExplorerQueryRepository(
fun getLiveRoomList(
creatorId: Long,
userMember: Member,
isAdult: Boolean,
timezone: String,
offset: Long = 0
): List<LiveRoomResponse> {
@@ -361,7 +362,8 @@ class ExplorerQueryRepository(
where = where.and(genderCondition.or(liveRoom.member.id.eq(userMember.id)))
}
if (userMember.auth == null) {
// 라이브 목록 노출은 호출부에서 계산한 정책 결과(isAdult)만 신뢰해 필터링한다.
if (!isAdult) {
where = where.and(liveRoom.isAdult.isFalse)
}

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.SortType
@@ -31,6 +30,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable
@@ -48,6 +48,7 @@ import kotlin.random.Random
@Transactional(readOnly = true)
class ExplorerService(
private val memberService: MemberService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val audioContentService: AudioContentService,
private val donationRankingService: CreatorDonationRankingService,
@@ -257,9 +258,15 @@ class ExplorerService(
fun getCreatorProfile(
creatorId: Long,
timezone: String,
isAdultContentVisible: Boolean,
isAdultContentVisible: Boolean?,
member: Member
): GetCreatorProfileResponse {
val preference = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = null
)
// 크리에이터(유저) 정보
val creatorAccount = queryRepository.getMember(creatorId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
@@ -307,6 +314,7 @@ class ExplorerService(
queryRepository.getLiveRoomList(
creatorId,
userMember = member,
isAdult = preference.isAdult,
timezone = timezone
)
} else {
@@ -318,8 +326,8 @@ class ExplorerService(
audioContentService.getAudioContentList(
creatorId = creatorId,
sortType = SortType.NEWEST,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member,
offset = 0,
limit = 3
@@ -348,7 +356,11 @@ class ExplorerService(
// 크리에이터의 최신 오디오 콘텐츠 1개
val latestContent = if (isCreator && !isBlock) {
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
audioContentService.getLatestCreatorAudioContent(
creatorId = creatorId,
member = member,
isAdultContentVisible = preference.isAdultContentVisible
)
} else {
null
}
@@ -382,7 +394,7 @@ class ExplorerService(
timezone = timezone,
offset = 0,
limit = 3,
isAdult = member.auth != null
isAdult = preference.isAdult
)
} else {
listOf()
@@ -412,8 +424,8 @@ class ExplorerService(
seriesService
.getSeriesList(
creatorId = creatorId,
isAdultContentVisible = isAdultContentVisible,
contentType = ContentType.ALL,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
member = member
)
.items

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.Create
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.lang.Nullable
import org.springframework.security.access.prepost.PreAuthorize
@@ -23,7 +24,10 @@ import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/creator-community")
class CreatorCommunityController(private val service: CreatorCommunityService) {
class CreatorCommunityController(
private val service: CreatorCommunityService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@PostMapping
@PreAuthorize("hasRole('CREATOR')")
fun createCommunityPost(
@@ -92,6 +96,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostList(
@@ -100,7 +105,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = member.auth != null
isAdult = isAdult
)
)
}
@@ -112,13 +117,14 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostDetail(
postId = postId,
memberId = member.id!!,
timezone = timezone,
isAdult = member.auth != null
isAdult = isAdult
)
)
}
@@ -129,8 +135,10 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
// 좋아요 대상 게시글 조회도 저장된 성인 노출 정책을 동일하게 적용한다.
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(service.communityPostLike(request, member))
ApiResponse.ok(service.communityPostLike(request, member, isAdult))
}
@PostMapping("/comment")
@@ -139,6 +147,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.createCommunityPostComment(
@@ -146,7 +155,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
postId = request.postId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member
member = member,
isAdult = isAdult
)
)
}
@@ -171,6 +181,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommunityPostCommentList(
@@ -178,7 +189,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
memberId = member.id!!,
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
limit = pageable.pageSize.toLong(),
isAdult = isAdult
)
)
}
@@ -191,6 +203,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getCommentReplyList(
@@ -198,7 +211,8 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
memberId = member.id!!,
timezone = timezone,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
limit = pageable.pageSize.toLong(),
isAdult = isAdult
)
)
}
@@ -209,12 +223,13 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.getLatestPostListFromCreatorsYouFollow(
timezone = timezone,
memberId = member.id!!,
isAdult = member.auth != null
isAdult = isAdult
)
)
}
@@ -225,13 +240,14 @@ class CreatorCommunityController(private val service: CreatorCommunityService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
ApiResponse.ok(
service.purchasePost(
postId = request.postId,
memberId = member.id!!,
timezone = request.timezone,
isAdult = member.auth != null,
isAdult = isAdult,
container = request.container
)
)

View File

@@ -380,14 +380,18 @@ class CreatorCommunityService(
}
@Transactional
fun communityPostLike(request: PostCommunityPostLikeRequest, member: Member): PostCommunityPostLikeResponse {
fun communityPostLike(
request: PostCommunityPostLikeRequest,
member: Member,
isAdult: Boolean
): PostCommunityPostLikeResponse {
var postLike = likeRepository.findByPostIdAndMemberId(postId = request.postId, memberId = member.id!!)
if (postLike == null) {
postLike = CreatorCommunityLike()
postLike.member = member
val post = repository.findByIdAndActive(request.postId, isAdult = member.auth != null)
val post = repository.findByIdAndActive(request.postId, isAdult = isAdult)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
postLike.creatorCommunity = post
@@ -405,10 +409,11 @@ class CreatorCommunityService(
comment: String,
postId: Long,
parentId: Long? = null,
isSecret: Boolean = false
isSecret: Boolean = false,
isAdult: Boolean
) {
val post = repository.findByIdOrNull(id = postId)
?: throw SodaException(messageKey = "creator.community.invalid_post_retry")
val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
val creatorId = post.member!!.id!!
@@ -480,10 +485,13 @@ class CreatorCommunityService(
memberId: Long,
timezone: String,
offset: Long,
limit: Long
limit: Long,
isAdult: Boolean
): GetCommunityPostCommentListResponse {
val post = repository.findByIdOrNull(id = postId)
if (post != null && isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) {
val post = repository.findByIdAndActive(postId = postId, isAdult = isAdult)
?: throw SodaException(messageKey = "creator.community.invalid_request_retry")
if (isBlockedBetweenMembers(memberId = memberId, creatorId = post.member!!.id!!)) {
return GetCommunityPostCommentListResponse(totalCount = 0, items = listOf())
}
@@ -509,9 +517,14 @@ class CreatorCommunityService(
memberId: Long,
timezone: String,
offset: Long,
limit: Long
limit: Long,
isAdult: Boolean
): GetCommunityPostCommentListResponse {
val parentComment = commentRepository.findByIdOrNull(id = commentId)
if (parentComment != null && !isAdult && parentComment.creatorCommunity!!.isAdult) {
throw SodaException(messageKey = "creator.community.invalid_request_retry")
}
if (
parentComment != null &&
isBlockedBetweenMembers(memberId = memberId, creatorId = parentComment.creatorCommunity!!.member!!.id!!)

View File

@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.live.recommend
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
@Service
class LiveRecommendCacheService(
private val repository: LiveRecommendRepository
) {
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#memberId ?: 'guest') + ':' + #isAdult"
)
fun getRecommendLive(memberId: Long?, isAdult: Boolean): List<GetRecommendLiveResponse> {
return repository.getRecommendLive(
memberId = memberId,
isAdult = isAdult
)
}
}

View File

@@ -3,29 +3,37 @@ package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.cache.annotation.Cacheable
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class LiveRecommendService(
private val repository: LiveRecommendRepository,
private val blockMemberRepository: BlockMemberRepository
private val blockMemberRepository: BlockMemberRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val liveRecommendCacheService: LiveRecommendCacheService
) {
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'getRecommendLive:' + (#member?.id ?: 'guest')"
)
fun getRecommendLive(member: Member?): List<GetRecommendLiveResponse> {
return repository.getRecommendLive(
val isAdult = if (member != null) {
memberContentPreferenceService.getStoredPreference(member).isAdult
} else {
false
}
return liveRecommendCacheService.getRecommendLive(
memberId = member?.id,
isAdult = member?.auth != null
isAdult = isAdult
)
}
fun getRecommendChannelList(member: Member?): List<GetRecommendChannelResponse> {
val isAdult = if (member != null) {
memberContentPreferenceService.getStoredPreference(member).isAdult
} else {
false
}
val onAirChannelList = repository.getOnAirRecommendChannelList(
isBlocked = {
if (member != null) {
@@ -35,7 +43,7 @@ class LiveRecommendService(
}
},
isCreator = member?.role == MemberRole.CREATOR,
isAdult = member?.auth != null
isAdult = isAdult
)
if (onAirChannelList.size >= 20) {
@@ -60,11 +68,13 @@ class LiveRecommendService(
}
fun getFollowingChannelList(member: Member): List<GetRecommendChannelResponse> {
val isAdult = memberContentPreferenceService.getStoredPreference(member).isAdult
val onAirFollowingChannelList = repository.getOnAirFollowingChannelList(
memberId = member.id!!,
isBlocked = { isBlockedBetweenMembers(memberId = member.id!!, creatorId = it) },
isCreator = member.role == MemberRole.CREATOR,
isAdult = member.auth != null
isAdult = isAdult
)
if (onAirFollowingChannelList.size >= 20) {

View File

@@ -43,7 +43,7 @@ class LiveRoomController(
service.getRoomList(
dateString,
status,
isAdultContentVisible ?: true,
isAdultContentVisible,
pageable,
member,
timezone

View File

@@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
@@ -63,6 +64,8 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.cache.annotation.Cacheable
@@ -106,6 +109,7 @@ class LiveRoomService(
private val pushTokenRepository: PushTokenRepository,
private val memberRepository: MemberRepository,
private val tagRepository: LiveTagRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val canRepository: CanRepository,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@@ -201,11 +205,13 @@ class LiveRoomService(
fun getRoomList(
dateString: String?,
status: LiveRoomStatus,
isAdultContentVisible: Boolean,
isAdultContentVisible: Boolean?,
pageable: Pageable,
member: Member?,
timezone: String
): List<GetRoomListResponse> {
val preference = resolvePreference(member, isAdultContentVisible)
val isAdult = preference.isAdult
val effectiveGender = member?.let {
if (it.auth != null) {
if (it.auth!!.gender == 1) Gender.MALE else Gender.FEMALE
@@ -219,7 +225,7 @@ class LiveRoomService(
timezone,
memberId = member?.id,
isCreator = member?.role == MemberRole.CREATOR,
isAdult = true,
isAdult = isAdult,
effectiveGender = effectiveGender
)
} else if (dateString != null) {
@@ -229,7 +235,7 @@ class LiveRoomService(
timezone,
memberId = member?.id,
isCreator = member?.role == MemberRole.CREATOR,
isAdult = member?.auth != null && isAdultContentVisible,
isAdult = isAdult,
effectiveGender = effectiveGender
)
} else {
@@ -237,7 +243,7 @@ class LiveRoomService(
timezone,
isCreator = member?.role == MemberRole.CREATOR,
memberId = member?.id,
isAdult = member?.auth != null && isAdultContentVisible,
isAdult = isAdult,
effectiveGender = effectiveGender
)
}
@@ -529,7 +535,8 @@ class LiveRoomService(
throw SodaException(messageKey = "live.room.already_ended")
}
if (room.isAdult && member.auth == null) {
val preference = memberContentPreferenceService.getStoredPreference(member)
if (room.isAdult && !preference.isAdult) {
throw SodaException(messageKey = "live.room.adult_verification_required")
}
@@ -770,6 +777,11 @@ class LiveRoomService(
val room = repository.getLiveRoom(id = request.roomId)
?: throw SodaException(messageKey = "live.room.not_found")
val preference = memberContentPreferenceService.getStoredPreference(member)
if (room.isAdult && !preference.isAdult) {
throw SodaException(messageKey = "live.room.adult_verification_required")
}
if (
room.member!!.id!! != member.id!! &&
room.type == LiveRoomType.PRIVATE &&
@@ -1458,6 +1470,23 @@ class LiveRoomService(
return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() }
}
private fun resolvePreference(member: Member?, isAdultContentVisible: Boolean?): ViewerContentPreference {
if (member == null) {
return ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = isAdultContentVisible ?: false,
contentType = ContentType.ALL,
isAdult = false
)
}
return memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = null
)
}
@Transactional
fun likeHeart(request: LiveRoomLikeHeartRequest, member: Member) {
val room = repository.findByIdOrNull(request.roomId)

View File

@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.live.tag
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.live.tag.QLiveTag.liveTag
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@@ -13,15 +11,15 @@ interface LiveTagRepository : JpaRepository<LiveTag, Long>, LiveTagQueryReposito
}
interface LiveTagQueryRepository {
fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse>
fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse>
}
@Repository
class LiveTagQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : LiveTagQueryRepository {
override fun getTags(member: Member, cloudFrontHost: String): List<GetLiveTagResponse> {
override fun getTags(isAdult: Boolean, cloudFrontHost: String): List<GetLiveTagResponse> {
var where = liveTag.isActive.isTrue
if (member.role != MemberRole.ADMIN && member.auth == null) {
if (!isAdult) {
where = where.and(liveTag.isAdult.isFalse)
}

View File

@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
@@ -15,6 +17,7 @@ import org.springframework.web.multipart.MultipartFile
@Service
class LiveTagService(
private val repository: LiveTagRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@@ -91,7 +94,15 @@ class LiveTagService(
}
fun getTags(member: Member): List<GetLiveTagResponse> {
return repository.getTags(member = member, cloudFrontHost = cloudFrontHost)
// 관리자 화면에서는 운영 확인 목적상 성인 태그까지 전체 조회를 허용한다.
val isAdult = if (member.role == MemberRole.ADMIN) {
true
} else {
// 일반 사용자는 저장된 선호 정책(isAdult) 기준으로만 태그 노출을 제한한다.
memberContentPreferenceService.getStoredPreference(member).isAdult
}
return repository.getTags(isAdult = isAdult, cloudFrontHost = cloudFrontHost)
}
fun tagExistCheck(request: CreateLiveTagRequest) {

View File

@@ -7,6 +7,9 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceRequest
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceResponse
import kr.co.vividnext.sodalive.member.following.CreatorFollowRequest
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.member.login.LoginResponse
@@ -20,6 +23,7 @@ import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.userdetails.User
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
@@ -35,6 +39,7 @@ import org.springframework.web.multipart.MultipartFile
@RequestMapping("/member")
class MemberController(
private val service: MemberService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val socialAuthServiceResolver: SocialAuthServiceResolver,
private val trackingService: AdTrackingService,
private val userActionService: UserActionService,
@@ -136,6 +141,27 @@ class MemberController(
ApiResponse.ok(service.getMemberInfo(member, container ?: "web"))
}
@PatchMapping("/content-preference")
fun updateContentPreference(
@RequestBody request: UpdateMemberContentPreferenceRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = request.isAdultContentVisible,
contentType = request.contentType
)
ApiResponse.ok(
UpdateMemberContentPreferenceResponse(
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType
)
)
}
@PostMapping("/notification")
fun updateNotificationSettings(
@RequestBody request: UpdateNotificationSettingRequest,

View File

@@ -17,7 +17,11 @@ import kr.co.vividnext.sodalive.member.notification.QMemberNotification.memberNo
import kr.co.vividnext.sodalive.message.QMessage.message
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository {
@@ -27,6 +31,10 @@ interface MemberRepository : JpaRepository<Member, Long>, MemberQueryRepository
fun findByKakaoId(kakaoId: Long): Member?
fun findByAppleId(appleId: String): Member?
fun findByLineId(lineId: String): Member?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Member m where m.id = :memberId")
fun findByIdForUpdate(@Param("memberId") memberId: Long): Member?
}
interface MemberQueryRepository {

View File

@@ -17,11 +17,11 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.GetBlockedMemberListResponse
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.info.GetMemberInfoResponse
@@ -82,7 +82,6 @@ class MemberService(
private val stipulationAgreeRepository: StipulationAgreeRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val blockMemberRepository: BlockMemberRepository,
private val authRepository: AuthRepository,
private val signOutRepository: SignOutRepository,
private val nicknameChangeLogRepository: NicknameChangeLogRepository,
private val memberTagRepository: MemberTagRepository,
@@ -106,6 +105,7 @@ class MemberService(
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
private val countryContext: CountryContext,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val objectMapper: ObjectMapper,
private val cacheManager: CacheManager,
@@ -120,6 +120,8 @@ class MemberService(
private val tokenLocks: MutableMap<Long, ReentrantReadWriteLock> = mutableMapOf()
private val recommendLiveCacheKeyPrefix = "getRecommendLive:"
private val recommendLiveCacheKeySuffixFalse = ":false"
private val recommendLiveCacheKeySuffixTrue = ":true"
private val latestFinishedLiveCacheKeyPrefix = "getLatestFinishedLive:"
@Transactional
@@ -154,6 +156,7 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (request.pushToken != null) {
@@ -192,6 +195,7 @@ class MemberService(
duplicateCheckNickname(request.nickname)
val member = createMember(request)
memberContentPreferenceService.initializeDefaultPreference(member)
member.profileImage = uploadProfileImage(profileImage = profileImage, memberId = member.id!!)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
@@ -217,6 +221,8 @@ class MemberService(
}
fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse {
val preference = memberContentPreferenceService.getStoredPreference(member)
val gender = if (member.auth != null) {
if (member.auth!!.gender == 1) {
messageSource.getMessage("member.gender.male", langContext.lang)
@@ -250,7 +256,10 @@ class MemberService(
messageNotice = member.notification?.message,
followingChannelLiveNotice = member.notification?.live,
followingChannelUploadContentNotice = member.notification?.uploadContent,
auditionNotice = member.notification?.audition
auditionNotice = member.notification?.audition,
countryCode = preference.countryCode,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType
)
}
@@ -840,7 +849,11 @@ class MemberService(
}
private fun evictRecommendLiveCache(memberId: Long) {
cacheManager.getCache("cache_ttl_3_hours")?.evict(recommendLiveCacheKeyPrefix + memberId)
val cache = cacheManager.getCache("cache_ttl_3_hours") ?: return
cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixFalse)
cache.evict(recommendLiveCacheKeyPrefix + memberId + recommendLiveCacheKeySuffixTrue)
cache.evict(recommendLiveCacheKeyPrefix + memberId)
}
private fun evictLatestFinishedLiveCache(memberId: Long) {
@@ -910,6 +923,7 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -967,6 +981,7 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -1024,6 +1039,7 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {
@@ -1081,6 +1097,7 @@ class MemberService(
}
repository.save(member)
memberContentPreferenceService.initializeDefaultPreference(member)
agreeTermsOfServiceAndPrivacyPolicy(member, stipulationTermsOfService, stipulationPrivacyPolicy)
if (pushToken != null) {

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/auth")
class AuthController(
private val service: AuthService,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val userActionService: UserActionService
) {
@PostMapping
@@ -32,6 +34,7 @@ class AuthController(
}
val authResponse = service.authenticate(authenticateData, member.id!!)
memberContentPreferenceService.markAdultVisibleAfterAuthVerify(member.id!!)
try {
userActionService.recordAction(

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.OneToOne
@Entity
class MemberContentPreference(
@Column(nullable = false)
var isAdultContentVisible: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var contentType: ContentType = ContentType.ALL,
@Column(nullable = false)
var adultContentVisibilityChangedAt: LocalDateTime = LocalDateTime.now(),
@Column(nullable = false)
var contentTypeChangedAt: LocalDateTime = LocalDateTime.now()
) : BaseEntity() {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false, unique = true)
var member: Member? = null
}

View File

@@ -0,0 +1,23 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L)
private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 40850L)
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"
}

View File

@@ -0,0 +1,19 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
fun resolveCountryCodeByPolicy(member: Member): String {
val requestAttributes = RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes
val requestCountryCode = requestAttributes?.request?.getHeader("CloudFront-Viewer-Country")
return resolveCountryCodeWithForcedMapping(member, requestCountryCode)
}
fun isAdultVisibleByPolicy(member: Member, isAdultContentVisible: Boolean): Boolean {
return if (resolveCountryCodeByPolicy(member) == "KR") {
member.auth != null && isAdultContentVisible
} else {
isAdultContentVisible
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.member.contentpreference
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface MemberContentPreferenceRepository : JpaRepository<MemberContentPreference, Long> {
fun findByMemberId(memberId: Long): MemberContentPreference?
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select mcp from MemberContentPreference mcp where mcp.member.id = :memberId")
fun findByMemberIdForUpdate(@Param("memberId") memberId: Long): MemberContentPreference?
}

View File

@@ -0,0 +1,247 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import org.springframework.cache.CacheManager
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class MemberContentPreferenceService(
private val repository: MemberContentPreferenceRepository,
private val memberRepository: MemberRepository,
private val countryContext: CountryContext,
private val cacheManager: CacheManager
) {
companion object {
private const val RECOMMEND_LIVE_CACHE_NAME = "cache_ttl_3_hours"
private const val RECOMMEND_LIVE_CACHE_KEY_PREFIX = "getRecommendLive:"
private const val RECOMMEND_LIVE_CACHE_KEY_SUFFIX_FALSE = ":false"
private const val RECOMMEND_LIVE_CACHE_KEY_SUFFIX_TRUE = ":true"
}
@Transactional
fun initializeDefaultPreference(member: Member): MemberContentPreference {
val memberId = requireMemberId(member)
val existingPreference = repository.findByMemberId(memberId)
if (existingPreference != null) {
return existingPreference
}
memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val lockedPreference = repository.findByMemberIdForUpdate(memberId)
if (lockedPreference != null) {
return lockedPreference
}
val now = LocalDateTime.now()
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
preference.member = member
return try {
repository.saveAndFlush(preference)
} catch (e: DataIntegrityViolationException) {
repository.findByMemberIdForUpdate(memberId) ?: throw e
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun resolveForQuery(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
val hasChanged = if (isAdultContentVisible != null || contentType != null) {
applyRequestValues(
preference = preference,
member = member,
countryCode = countryCode,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
} else {
false
}
if (hasChanged) {
evictRecommendLiveCacheAfterCommit(requireMemberId(member))
}
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
@Transactional
fun updatePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): ViewerContentPreference {
if (isAdultContentVisible == null && contentType == null) {
throw SodaException(messageKey = "common.error.invalid_request")
}
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
val hasChanged = applyRequestValues(
preference = preference,
member = member,
countryCode = countryCode,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
if (hasChanged) {
evictRecommendLiveCacheAfterCommit(requireMemberId(member))
}
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
@Transactional
fun markAdultVisibleAfterAuthVerify(memberId: Long) {
val member = memberRepository.findByIdOrNull(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
val preference = initializeDefaultPreference(member)
if (!preference.isAdultContentVisible) {
preference.isAdultContentVisible = true
preference.adultContentVisibilityChangedAt = LocalDateTime.now()
evictRecommendLiveCacheAfterCommit(memberId)
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun getStoredPreference(member: Member): ViewerContentPreference {
val preference = initializeDefaultPreference(member)
val countryCode = resolveCountryCode(member)
return toViewerContentPreference(
countryCode = countryCode,
member = member,
preference = preference
)
}
fun resolveCountryCode(member: Member): String {
requireMemberId(member)
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
}
fun calculateIsAdultForQuery(
member: Member,
countryCode: String,
isAdultContentVisible: Boolean
): Boolean {
return if (countryCode == "KR") {
isAdultContentVisible && member.auth != null
} else {
isAdultContentVisible
}
}
private fun applyRequestValues(
preference: MemberContentPreference,
member: Member,
countryCode: String,
isAdultContentVisible: Boolean?,
contentType: ContentType?
): Boolean {
val shouldApplyByCountryPolicy = countryCode != "KR" || member.auth != null
if (!shouldApplyByCountryPolicy) {
return false
}
val now = LocalDateTime.now()
var hasChanged = false
if (
isAdultContentVisible != null &&
preference.isAdultContentVisible != isAdultContentVisible
) {
preference.isAdultContentVisible = isAdultContentVisible
preference.adultContentVisibilityChangedAt = now
hasChanged = true
}
if (contentType != null && preference.contentType != contentType) {
preference.contentType = contentType
preference.contentTypeChangedAt = now
hasChanged = true
}
return hasChanged
}
private fun evictRecommendLiveCacheAfterCommit(memberId: Long) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() {
evictRecommendLiveCache(memberId)
}
}
)
return
}
evictRecommendLiveCache(memberId)
}
private fun evictRecommendLiveCache(memberId: Long) {
val cache = cacheManager.getCache(RECOMMEND_LIVE_CACHE_NAME) ?: return
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId + RECOMMEND_LIVE_CACHE_KEY_SUFFIX_FALSE)
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId + RECOMMEND_LIVE_CACHE_KEY_SUFFIX_TRUE)
cache.evict(RECOMMEND_LIVE_CACHE_KEY_PREFIX + memberId)
}
private fun toViewerContentPreference(
countryCode: String,
member: Member,
preference: MemberContentPreference
): ViewerContentPreference {
return ViewerContentPreference(
countryCode = countryCode,
isAdultContentVisible = preference.isAdultContentVisible,
contentType = preference.contentType,
isAdult = calculateIsAdultForQuery(
member = member,
countryCode = countryCode,
isAdultContentVisible = preference.isAdultContentVisible
)
)
}
private fun requireMemberId(member: Member): Long {
return member.id ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class UpdateMemberContentPreferenceRequest(
val isAdultContentVisible: Boolean? = null,
val contentType: ContentType? = null
)

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class UpdateMemberContentPreferenceResponse(
val isAdultContentVisible: Boolean,
val contentType: ContentType
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.content.ContentType
data class ViewerContentPreference(
val countryCode: String,
val isAdultContentVisible: Boolean,
val contentType: ContentType,
val isAdult: Boolean
)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.member.info
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.MemberRole
data class GetMemberInfoResponse(
@@ -13,5 +14,8 @@ data class GetMemberInfoResponse(
val messageNotice: Boolean?,
val followingChannelLiveNotice: Boolean?,
val followingChannelUploadContentNotice: Boolean?,
val auditionNotice: Boolean?
val auditionNotice: Boolean?,
val countryCode: String,
val isAdultContentVisible: Boolean,
val contentType: ContentType
)

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
@@ -13,7 +14,10 @@ import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/search")
class SearchController(private val service: SearchService) {
class SearchController(
private val service: SearchService,
private val memberContentPreferenceService: MemberContentPreferenceService
) {
@GetMapping
fun searchUnified(
@RequestParam keyword: String,
@@ -22,11 +26,12 @@ class SearchController(private val service: SearchService) {
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.searchUnified(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
member = member
)
)
@@ -35,8 +40,6 @@ class SearchController(private val service: SearchService) {
@GetMapping("/creators")
fun searchCreatorList(
@RequestParam keyword: String,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable
) = run {
@@ -44,8 +47,6 @@ class SearchController(private val service: SearchService) {
ApiResponse.ok(
service.searchCreatorList(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -62,11 +63,12 @@ class SearchController(private val service: SearchService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.searchContentList(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
@@ -83,15 +85,26 @@ class SearchController(private val service: SearchService) {
pageable: Pageable
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member, isAdultContentVisible, contentType)
ApiResponse.ok(
service.searchSeriesList(
keyword,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
isAdult = preference.isAdult,
contentType = preference.contentType,
member = member,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
)
}
private fun resolvePreference(
member: Member,
isAdultContentVisible: Boolean?,
contentType: ContentType?
) = memberContentPreferenceService.resolveForQuery(
member = member,
isAdultContentVisible = isAdultContentVisible,
contentType = contentType
)
}

View File

@@ -8,12 +8,10 @@ import org.springframework.stereotype.Service
class SearchService(private val repository: SearchRepository) {
fun searchUnified(
keyword: String,
isAdultContentVisible: Boolean,
isAdult: Boolean,
contentType: ContentType,
member: Member
): SearchUnifiedResponse {
val isAdult = member.auth != null && isAdultContentVisible
val creatorList = repository.searchCreatorList(
keyword = keyword,
memberId = member.id!!,
@@ -60,8 +58,6 @@ class SearchService(private val repository: SearchRepository) {
fun searchCreatorList(
keyword: String,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
@@ -83,14 +79,12 @@ class SearchService(private val repository: SearchRepository) {
fun searchContentList(
keyword: String,
isAdultContentVisible: Boolean,
isAdult: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
): SearchResponse {
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = repository.searchContentTotalCount(
keyword,
memberId = member.id!!,
@@ -116,14 +110,12 @@ class SearchService(private val repository: SearchRepository) {
fun searchSeriesList(
keyword: String,
isAdultContentVisible: Boolean,
isAdult: Boolean,
contentType: ContentType,
member: Member,
offset: Long,
limit: Long
): SearchResponse {
val isAdult = member.auth != null && isAdultContentVisible
val totalCount = repository.searchSeriesTotalCount(
keyword,
memberId = member.id!!,

View File

@@ -100,6 +100,28 @@ class AudioContentServiceTest {
)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 콘텐츠 상세를 조회하면 인증 필요 예외를 반환한다")
fun shouldThrowAdultVerificationRequiredWhenAdultContentRequestedByNonAdultPolicy() {
val viewer = createMember(id = 1002L, nickname = "viewer")
val creator = createMember(id = 2002L, nickname = "creator")
val adultContent = createAudioContent(creator = creator, isAdult = true)
Mockito.`when`(repository.findById(adultContent.id!!)).thenReturn(Optional.of(adultContent))
val exception = assertThrows(SodaException::class.java) {
service.getDetail(
id = adultContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
}
assertEquals("common.error.adult_verification_required", exception.messageKey)
Mockito.verifyNoInteractions(explorerQueryRepository)
}
@Test
@DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다")
fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() {
@@ -220,7 +242,7 @@ class AudioContentServiceTest {
return member
}
private fun createAudioContent(creator: Member): AudioContent {
private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent {
val theme = AudioContentTheme(theme = "수면", image = "sleep.png")
theme.id = 300L
@@ -232,7 +254,7 @@ class AudioContentServiceTest {
purchaseOption = PurchaseOption.BOTH,
isGeneratePreview = true,
isOnlyRental = false,
isAdult = false,
isAdult = isAdult,
isPointAvailable = true,
isCommentAvailable = true,
isFullDetailVisible = true

View File

@@ -8,7 +8,9 @@ import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommunityPostLikeRequest
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -36,6 +38,7 @@ import java.util.Optional
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
@@ -45,6 +48,7 @@ class CreatorCommunityServiceTest {
fun setup() {
repository = Mockito.mock(CreatorCommunityRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
@@ -53,7 +57,7 @@ class CreatorCommunityServiceTest {
canPaymentService = Mockito.mock(CanPaymentService::class.java),
repository = repository,
blockMemberRepository = blockMemberRepository,
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
likeRepository = likeRepository,
commentRepository = commentRepository,
useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
@@ -68,6 +72,29 @@ class CreatorCommunityServiceTest {
)
}
@Test
@DisplayName("좋아요 처리 시 전달된 성인 여부를 기준으로 게시글을 조회한다")
fun shouldUseProvidedIsAdultForCommunityLikeAdultFilter() {
val member = createMember(id = 88L, role = MemberRole.USER, nickname = "viewer")
val post = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
post.id = 801L
post.member = createMember(id = 99L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(likeRepository.findByPostIdAndMemberId(postId = 801L, memberId = 88L)).thenReturn(null)
Mockito.`when`(repository.findByIdAndActive(801L, true)).thenReturn(post)
Mockito.`when`(likeRepository.save(Mockito.any(CreatorCommunityLike::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
val response = service.communityPostLike(
request = PostCommunityPostLikeRequest(postId = 801L),
member = member,
isAdult = true
)
assertTrue(response.like)
Mockito.verify(repository).findByIdAndActive(801L, true)
}
@Test
@DisplayName("크리에이터가 아닌 사용자가 댓글 작성 시 크리에이터 대상 커뮤니티 딥링크 알림 이벤트를 발행한다")
fun shouldPublishCreatorCommunityCommentNotificationEventWhenCommenterIsNotCreator() {
@@ -77,7 +104,7 @@ class CreatorCommunityServiceTest {
post.id = 301L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, commenter.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -87,7 +114,8 @@ class CreatorCommunityServiceTest {
comment = "새 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
isSecret = false,
isAdult = true
)
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
@@ -112,7 +140,7 @@ class CreatorCommunityServiceTest {
post.id = 401L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(repository.findByIdAndActive(post.id!!, true)).thenReturn(post)
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, creator.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
@@ -122,12 +150,80 @@ class CreatorCommunityServiceTest {
comment = "내가 단 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
isSecret = false,
isAdult = true
)
Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any())
}
@Test
@DisplayName("비성인 정책 사용자가 성인 커뮤니티 게시글에 댓글 작성 시 예외가 발생한다")
fun shouldThrowExceptionWhenCommentingAdultPostWithNonAdultPolicy() {
val commenter = createMember(id = 23L, role = MemberRole.USER, nickname = "viewer")
Mockito.`when`(repository.findByIdAndActive(901L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.createCommunityPostComment(
member = commenter,
comment = "접근 불가 댓글",
postId = 901L,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingAdultPostCommentsWithNonAdultPolicy() {
Mockito.`when`(repository.findByIdAndActive(902L, false)).thenReturn(null)
val exception = assertThrows(SodaException::class.java) {
service.getCommunityPostCommentList(
postId = 902L,
memberId = 23L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("비성인 정책 사용자가 성인 게시글 댓글의 답글 목록을 조회하면 예외가 발생한다")
fun shouldThrowExceptionWhenFetchingReplyOfAdultPostWithNonAdultPolicy() {
val creator = createMember(id = 31L, role = MemberRole.CREATOR, nickname = "creator")
val adultPost = CreatorCommunity(content = "adult-post", price = 0, isCommentAvailable = true, isAdult = true)
adultPost.id = 903L
adultPost.member = creator
val parentComment = CreatorCommunityComment(comment = "parent", isSecret = false)
parentComment.id = 1001L
parentComment.creatorCommunity = adultPost
parentComment.member = creator
Mockito.`when`(commentRepository.findById(1001L)).thenReturn(Optional.of(parentComment))
val exception = assertThrows(SodaException::class.java) {
service.getCommentReplyList(
commentId = 1001L,
memberId = 32L,
timezone = "Asia/Seoul",
offset = 0,
limit = 10,
isAdult = false
)
}
assertEquals("creator.community.invalid_request_retry", exception.messageKey)
}
@Test
@DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다")
fun shouldThrowExceptionWhenPinCountExceedsLimit() {

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.live.recommend
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -11,13 +13,22 @@ import org.mockito.Mockito
class LiveRecommendServiceTest {
private lateinit var repository: LiveRecommendRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var liveRecommendCacheService: LiveRecommendCacheService
private lateinit var service: LiveRecommendService
@BeforeEach
fun setup() {
repository = Mockito.mock(LiveRecommendRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
service = LiveRecommendService(repository, blockMemberRepository)
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
liveRecommendCacheService = Mockito.mock(LiveRecommendCacheService::class.java)
service = LiveRecommendService(
repository = repository,
blockMemberRepository = blockMemberRepository,
memberContentPreferenceService = memberContentPreferenceService,
liveRecommendCacheService = liveRecommendCacheService
)
}
@Test
@@ -39,24 +50,35 @@ class LiveRecommendServiceTest {
auth.member = member
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend.png", creatorId = 77L))
Mockito.`when`(repository.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = kr.co.vividnext.sodalive.content.ContentType.ALL,
isAdult = true
)
)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = member.id, isAdult = true)).thenReturn(expected)
val result = service.getRecommendLive(member)
assertEquals(expected, result)
Mockito.verify(repository).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = member.id, isAdult = true)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
}
@Test
fun shouldDelegateToRepositoryAsGuestWhenMemberIsNull() {
val expected = listOf(GetRecommendLiveResponse(imageUrl = "https://cdn.test/recommend-guest.png", creatorId = 88L))
Mockito.`when`(repository.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
Mockito.`when`(liveRecommendCacheService.getRecommendLive(memberId = null, isAdult = false)).thenReturn(expected)
val result = service.getRecommendLive(null)
assertEquals(expected, result)
Mockito.verify(repository).getRecommendLive(memberId = null, isAdult = false)
Mockito.verify(liveRecommendCacheService).getRecommendLive(memberId = null, isAdult = false)
Mockito.verifyNoInteractions(repository)
Mockito.verifyNoInteractions(blockMemberRepository)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.live.tag
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LiveTagServiceTest {
private lateinit var repository: LiveTagRepository
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var service: LiveTagService
@BeforeEach
fun setup() {
repository = mock()
memberContentPreferenceService = mock()
service = LiveTagService(
repository = repository,
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
s3Uploader = mock<S3Uploader>(),
coverImageBucket = "bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("일반 사용자는 저장된 성인 설정값으로 라이브 태그 필터를 적용한다")
fun shouldApplyStoredPreferenceForNonAdminMember() {
val member = createMember(id = 1L, role = MemberRole.USER)
val expected = listOf(GetLiveTagResponse(1L, "일반", "https://cdn.test/live1.png", false))
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = false,
contentType = ContentType.ALL,
isAdult = false
)
)
Mockito.`when`(repository.getTags(isAdult = false, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(member)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = false, cloudFrontHost = "https://cdn.test")
}
@Test
@DisplayName("관리자는 저장 설정과 무관하게 성인 태그를 포함해 조회한다")
fun shouldAllowAdultTagsForAdmin() {
val admin = createMember(id = 2L, role = MemberRole.ADMIN)
val expected = listOf(GetLiveTagResponse(2L, "성인", "https://cdn.test/live2.png", true))
Mockito.`when`(repository.getTags(isAdult = true, cloudFrontHost = "https://cdn.test")).thenReturn(expected)
val actual = service.getTags(admin)
assertEquals(expected, actual)
Mockito.verify(repository).getTags(isAdult = true, cloudFrontHost = "https://cdn.test")
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long, role: MemberRole): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
role = role
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,130 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.marketing.AdTrackingService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.UpdateMemberContentPreferenceRequest
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.SocialAuthServiceResolver
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class MemberControllerTest {
private lateinit var memberService: MemberService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var socialAuthServiceResolver: SocialAuthServiceResolver
private lateinit var trackingService: AdTrackingService
private lateinit var userActionService: UserActionService
private lateinit var controller: MemberController
@BeforeEach
fun setup() {
memberService = mock()
memberContentPreferenceService = mock()
socialAuthServiceResolver = mock()
trackingService = mock()
userActionService = mock()
controller = MemberController(
service = memberService,
memberContentPreferenceService = memberContentPreferenceService,
socialAuthServiceResolver = socialAuthServiceResolver,
trackingService = trackingService,
userActionService = userActionService,
messageSource = SodaMessageSource(),
langContext = LangContext()
)
}
@Test
@DisplayName("PATCH /member/content-preference는 저장된 최신 설정을 응답한다")
fun shouldReturnUpdatedPreferenceWhenRequestIsValid() {
val member = createMember(1L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val viewerPreference = ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.FEMALE,
isAdult = true
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
).thenReturn(viewerPreference)
val response = controller.updateContentPreference(request, member)
assertTrue(response.success)
assertEquals(true, response.data?.isAdultContentVisible)
assertEquals(ContentType.FEMALE, response.data?.contentType)
}
@Test
@DisplayName("비로그인 사용자는 PATCH /member/content-preference 호출 시 예외가 발생한다")
fun shouldThrowWhenMemberIsNullOnUpdateContentPreference() {
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = true,
contentType = ContentType.ALL
)
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, null)
}
assertEquals("common.error.bad_credentials", exception.messageKey)
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
@Test
@DisplayName("두 필드 모두 누락된 PATCH 요청은 서비스 예외를 그대로 전파한다")
fun shouldPropagateServiceExceptionWhenBothFieldsAreMissing() {
val member = createMember(2L)
val request = UpdateMemberContentPreferenceRequest(
isAdultContentVisible = null,
contentType = null
)
Mockito.`when`(
memberContentPreferenceService.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
).thenThrow(SodaException(messageKey = "common.error.invalid_request"))
val exception = assertThrows(SodaException::class.java) {
controller.updateContentPreference(request, member)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.block.MemberBlockRequest
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -42,7 +43,6 @@ class MemberServiceCacheEvictionTest {
stipulationAgreeRepository = mock(),
creatorFollowingRepository = mock(),
blockMemberRepository = blockMemberRepository,
authRepository = authRepository,
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
@@ -63,6 +63,7 @@ class MemberServiceCacheEvictionTest {
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = mock<MemberContentPreferenceService>(),
objectMapper = ObjectMapper(),
cacheManager = cacheManager,
s3Bucket = "test-bucket",
@@ -88,8 +89,8 @@ class MemberServiceCacheEvictionTest {
service.memberBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
Mockito.verifyNoInteractions(authRepository)
}
@@ -140,9 +141,9 @@ class MemberServiceCacheEvictionTest {
blockedMemberId = linkedMemberId,
memberId = memberId
)
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$linkedMemberId")
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
verifyRecommendLiveCacheNotEvicted(linkedMemberId)
Mockito.verifyNoInteractions(authRepository)
}
@@ -162,8 +163,20 @@ class MemberServiceCacheEvictionTest {
service.memberUnBlock(MemberBlockRequest(blockMemberId = blockedMemberId), memberId)
assertEquals(false, blockMember.isActive)
verifyRecommendLiveCacheEvicted(memberId)
verifyRecommendLiveCacheEvicted(blockedMemberId)
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(cache).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache).evict("getRecommendLive:$memberId")
Mockito.verify(cache).evict("getRecommendLive:$blockedMemberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(cache, Mockito.never()).evict("getRecommendLive:$memberId")
}
private fun createMember(id: Long, nickname: String): Member {

View File

@@ -0,0 +1,165 @@
package kr.co.vividnext.sodalive.member
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.member.social.google.GoogleUserInfo
import kr.co.vividnext.sodalive.member.stipulation.Stipulation
import kr.co.vividnext.sodalive.member.stipulation.StipulationAgreeRepository
import kr.co.vividnext.sodalive.member.stipulation.StipulationIds
import kr.co.vividnext.sodalive.member.stipulation.StipulationRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.cache.CacheManager
import java.time.LocalDateTime
import java.util.Optional
class MemberServiceContentPreferenceTest {
private lateinit var repository: MemberRepository
private lateinit var stipulationRepository: StipulationRepository
private lateinit var stipulationAgreeRepository: StipulationAgreeRepository
private lateinit var nicknameGenerateService: kr.co.vividnext.sodalive.member.nickname.NicknameGenerateService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var chargeRepository: kr.co.vividnext.sodalive.can.charge.ChargeRepository
private lateinit var memberPointRepository: kr.co.vividnext.sodalive.point.MemberPointRepository
private lateinit var pushTokenService: kr.co.vividnext.sodalive.fcm.PushTokenService
private lateinit var service: MemberService
@BeforeEach
fun setup() {
repository = mock()
stipulationRepository = mock()
stipulationAgreeRepository = mock()
nicknameGenerateService = mock()
memberContentPreferenceService = mock()
chargeRepository = mock()
memberPointRepository = mock()
pushTokenService = mock()
service = MemberService(
repository = repository,
tokenRepository = mock(),
stipulationRepository = stipulationRepository,
stipulationAgreeRepository = stipulationAgreeRepository,
creatorFollowingRepository = mock(),
blockMemberRepository = mock(),
signOutRepository = mock(),
nicknameChangeLogRepository = mock(),
memberTagRepository = mock(),
liveReservationRepository = mock(),
chargeRepository = chargeRepository,
memberPointRepository = memberPointRepository,
orderService = mock(),
emailService = mock(),
pushTokenService = pushTokenService,
canPaymentService = mock(),
nicknameGenerateService = nicknameGenerateService,
memberNotificationService = mock(),
s3Uploader = mock(),
validator = mock(),
tokenProvider = mock(),
passwordEncoder = mock(),
authenticationManagerBuilder = mock(),
messageSource = SodaMessageSource(),
langContext = LangContext(),
countryContext = CountryContext(),
memberContentPreferenceService = memberContentPreferenceService,
objectMapper = ObjectMapper(),
cacheManager = mock<CacheManager>(),
s3Bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
}
@Test
@DisplayName("getMemberInfo는 저장된 콘텐츠 설정 필드를 그대로 반환한다")
fun shouldReturnStoredPreferenceFieldsInMemberInfo() {
val member = createMember(1L)
member.createdAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val preference = ViewerContentPreference(
countryCode = "JP",
isAdultContentVisible = true,
contentType = ContentType.MALE,
isAdult = true
)
Mockito.`when`(memberContentPreferenceService.getStoredPreference(member)).thenReturn(preference)
Mockito.`when`(chargeRepository.getChargeCount(1L)).thenReturn(3)
Mockito.`when`(
memberPointRepository.findByMemberIdAndExpiresAtAfterOrderByExpiresAtAsc(
memberId = Mockito.eq(1L),
expiresAt = anyLocalDateTime()
)
).thenReturn(emptyList())
val response = service.getMemberInfo(member, "web")
assertEquals("JP", response.countryCode)
assertEquals(true, response.isAdultContentVisible)
assertEquals(ContentType.MALE, response.contentType)
}
@Test
@DisplayName("Google 소셜 회원 신규 생성 시 기본 콘텐츠 설정을 선저장한다")
fun shouldInitializePreferenceWhenGoogleMemberIsRegistered() {
var savedMember: Member? = null
val terms = Stipulation(title = "terms", description = "desc")
terms.id = StipulationIds.TERMS_OF_SERVICE_ID
val privacy = Stipulation(title = "privacy", description = "desc")
privacy.id = StipulationIds.PRIVACY_POLICY_ID
Mockito.`when`(repository.findByGoogleId("sub-1")).thenReturn(null)
Mockito.`when`(repository.findByEmail("google@test.com")).thenReturn(null)
Mockito.`when`(stipulationRepository.findById(StipulationIds.TERMS_OF_SERVICE_ID)).thenReturn(Optional.of(terms))
Mockito.`when`(stipulationRepository.findById(StipulationIds.PRIVACY_POLICY_ID)).thenReturn(Optional.of(privacy))
Mockito.`when`(nicknameGenerateService.generateUniqueNickname(anyLang())).thenReturn("newbie")
Mockito.`when`(repository.save(Mockito.any(Member::class.java))).thenAnswer { invocation ->
val saved = invocation.getArgument<Member>(0)
saved.id = 10L
savedMember = saved
saved
}
Mockito.`when`(stipulationAgreeRepository.save(Mockito.any())).thenAnswer { invocation -> invocation.getArgument(0) }
val result = service.findOrRegister(
googleUserInfo = GoogleUserInfo(sub = "sub-1", email = "google@test.com", name = "google-user"),
container = "web",
marketingPid = null,
pushToken = null
)
assertTrue(result.isNew)
Mockito.verify(memberContentPreferenceService).initializeDefaultPreference(savedMember!!)
assertEquals(10L, savedMember!!.id)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
private fun anyLocalDateTime(): LocalDateTime =
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
private fun anyLang(): Lang =
Mockito.any(Lang::class.java) ?: Lang.KO
}

View File

@@ -0,0 +1,109 @@
package kr.co.vividnext.sodalive.member.auth
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.useraction.ActionType
import kr.co.vividnext.sodalive.useraction.UserActionService
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class AuthControllerTest {
private lateinit var authService: AuthService
private lateinit var memberContentPreferenceService: MemberContentPreferenceService
private lateinit var userActionService: UserActionService
private lateinit var controller: AuthController
@BeforeEach
fun setup() {
authService = mock()
memberContentPreferenceService = mock()
userActionService = mock()
controller = AuthController(
service = authService,
memberContentPreferenceService = memberContentPreferenceService,
userActionService = userActionService
)
}
@Test
@DisplayName("authVerify 성공 시 성인노출 true 저장을 호출한다")
fun shouldSaveAdultPreferenceWhenAuthVerifySucceeds() {
val member = createMember(id = 10L)
val request = AuthVerifyRequest(receiptId = "receipt-1", version = "v1")
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-1",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 10L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(false)
Mockito.`when`(authService.authenticate(certificate, 10L)).thenReturn(AuthResponse(gender = 1))
controller.authVerify(request, member)
Mockito.verify(memberContentPreferenceService).markAdultVisibleAfterAuthVerify(10L)
Mockito.verify(userActionService).recordAction(
memberId = 10L,
isAuth = true,
actionType = ActionType.USER_AUTHENTICATION
)
}
@Test
@DisplayName("차단 정책으로 authVerify가 실패하면 저장을 호출하지 않는다")
fun shouldNotSaveAdultPreferenceWhenAuthIsBlocked() {
val member = createMember(id = 20L)
val request = AuthVerifyRequest(receiptId = "receipt-2", version = null)
val certificate = AuthVerifyCertificate(
name = "홍길동",
birth = "19900101",
unique = "unique-ci",
di = "di-2",
gender = 1
)
Mockito.`when`(authService.certificate(request, memberId = 20L)).thenReturn(certificate)
Mockito.`when`(authService.isBlockAuth(certificate)).thenReturn(true)
assertThrows(SodaException::class.java) {
controller.authVerify(request, member)
}
Mockito.verify(authService).signOut(20L)
Mockito.verify(memberContentPreferenceService, Mockito.never()).markAdultVisibleAfterAuthVerify(Mockito.anyLong())
}
@Test
@DisplayName("비로그인 사용자는 authVerify 요청 시 예외를 반환한다")
fun shouldThrowWhenMemberIsNull() {
val request = AuthVerifyRequest(receiptId = "receipt-3", version = null)
assertThrows(SodaException::class.java) {
controller.authVerify(request, null)
}
Mockito.verifyNoInteractions(memberContentPreferenceService)
}
private fun createMember(id: Long): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
return member
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.cache.concurrent.ConcurrentMapCacheManager
import org.springframework.context.annotation.Import
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class MemberContentPreferenceIntegrationTest @Autowired constructor(
private val memberRepository: MemberRepository,
private val authRepository: AuthRepository,
private val preferenceRepository: MemberContentPreferenceRepository,
private val entityManager: EntityManager
) {
companion object {
private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 40850L)
}
private lateinit var service: MemberContentPreferenceService
private lateinit var countryContext: CountryContext
@BeforeEach
fun setup() {
countryContext = CountryContext()
service = MemberContentPreferenceService(
repository = preferenceRepository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = ConcurrentMapCacheManager("cache_ttl_3_hours")
)
}
@Test
@DisplayName("legacy 파라미터 최초 호출 시 row를 생성하고 같은 흐름에서 저장값 조회에 즉시 반영한다")
fun shouldCreateRowAndReflectImmediatelyOnFirstLegacyResolveCall() {
val member = saveNonForcedMember("legacy-user")
countryContext.setCountryCode("US")
assertEquals(null, preferenceRepository.findByMemberId(member.id!!))
val resolved = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
val stored = service.getStoredPreference(member)
assertNotNull(preferenceRepository.findByMemberId(member.id!!))
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.MALE, resolved.contentType)
assertEquals("US", resolved.countryCode)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.MALE, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("직접 설정 저장(updatePreference) 후 즉시 getStoredPreference에 반영된다")
fun shouldPersistAndReflectAfterDirectUpdate() {
val member = saveNonForcedMember("patch-user")
countryContext.setCountryCode("US")
val updated = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
val stored = service.getStoredPreference(member)
assertTrue(updated.isAdultContentVisible)
assertEquals(ContentType.FEMALE, updated.contentType)
assertTrue(stored.isAdultContentVisible)
assertEquals(ContentType.FEMALE, stored.contentType)
assertTrue(stored.isAdult)
}
@Test
@DisplayName("KR 헤더 누락 + 미인증 사용자는 요청값을 보내도 기본값을 유지한다")
fun shouldKeepDefaultValuesForKrUnauthenticatedWhenHeaderMissing() {
val member = saveNonForcedMember("kr-unauth-user")
countryContext.setCountryCode(null)
val resolved = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
val stored = service.getStoredPreference(member)
assertEquals("KR", resolved.countryCode)
assertFalse(resolved.isAdultContentVisible)
assertEquals(ContentType.ALL, resolved.contentType)
assertFalse(resolved.isAdult)
assertFalse(stored.isAdultContentVisible)
assertEquals(ContentType.ALL, stored.contentType)
}
@Test
@DisplayName("KR + 인증 사용자는 요청값이 저장되고 성인 조회값(isAdult)이 true로 계산된다")
fun shouldApplyRequestValuesForKrAuthenticatedMember() {
val member = saveNonForcedMember("kr-auth-user")
countryContext.setCountryCode(null)
saveAuth(member)
val reloadedMember = memberRepository.findById(member.id!!).orElseThrow()
val resolved = service.resolveForQuery(
member = reloadedMember,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("KR", resolved.countryCode)
assertTrue(resolved.isAdultContentVisible)
assertEquals(ContentType.FEMALE, resolved.contentType)
assertTrue(resolved.isAdult)
}
@Test
@DisplayName("authVerify 성공 후 markAdultVisibleAfterAuthVerify를 호출하면 저장값이 true로 반영된다")
fun shouldMarkAdultVisibleAfterAuthVerify() {
val member = saveNonForcedMember("auth-verified-user")
countryContext.setCountryCode("US")
service.updatePreference(member, isAdultContentVisible = false, contentType = ContentType.ALL)
service.markAdultVisibleAfterAuthVerify(member.id!!)
val stored = service.getStoredPreference(member)
assertTrue(stored.isAdultContentVisible)
}
@Test
@DisplayName("강제 매핑 회원 ID는 접속 국가 헤더보다 우선한다")
fun shouldReturnForcedCountryCodeRegardlessOfHeader() {
countryContext.setCountryCode("US")
val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member").apply { id = 2L }
val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member").apply { id = 16L }
assertEquals("JP", service.resolveCountryCode(jpMember))
assertEquals("KR", service.resolveCountryCode(krMember))
}
@Test
@DisplayName("강제 매핑 대상이 아니면 국가 코드는 접속 국가 헤더를 기준으로 계산된다")
fun shouldResolveCountryCodeByConnectionCountryHeaderForNonForcedMember() {
val member = saveNonForcedMember("country-user")
countryContext.setCountryCode("US")
assertEquals("US", service.resolveCountryCode(member))
countryContext.setCountryCode(null)
assertEquals("KR", service.resolveCountryCode(member))
}
private fun saveMember(seed: String): Member {
return memberRepository.saveAndFlush(
Member(
email = "$seed@test.com",
password = "password",
nickname = seed
)
)
}
private fun saveNonForcedMember(seed: String): Member {
var index = 0
while (true) {
val candidate = saveMember("$seed-$index")
if (!FORCED_MEMBER_IDS.contains(candidate.id)) {
return candidate
}
index++
}
}
private fun saveAuth(member: Member) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-ci-${member.id}",
di = "di-${member.id}",
gender = 1
)
auth.member = member
authRepository.saveAndFlush(auth)
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
class MemberContentPreferencePolicyTest {
@AfterEach
fun cleanup() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("요청 국가 헤더를 기준으로 국가 코드를 계산한다")
fun shouldResolveCountryCodeByRequestHeader() {
setRequestCountry(" us ")
val member = createMember(id = 200L, countryCode = "KR")
assertEquals("US", resolveCountryCodeByPolicy(member))
}
@Test
@DisplayName("강제 매핑 대상 회원 ID는 요청 국가 헤더보다 우선한다")
fun shouldPrioritizeForcedCountryMapping() {
setRequestCountry("US")
val forcedJpMember = createMember(id = 2L, countryCode = "KR")
val forcedKrMember = createMember(id = 16L, countryCode = "US")
assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember))
assertEquals("KR", resolveCountryCodeByPolicy(forcedKrMember))
}
@Test
@DisplayName("요청 국가가 KR이면 인증 미완료 사용자는 성인 노출이 false다")
fun shouldHideAdultContentForKrWithoutAuth() {
setRequestCountry("KR")
val member = createMember(id = 1L, countryCode = "US")
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 국가가 KR이 아니면 멤버 countryCode와 무관하게 전달값을 사용한다")
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
setRequestCountry("US")
val member = createMember(id = 201L, countryCode = "KR")
assertTrue(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
@Test
@DisplayName("요청 컨텍스트가 없으면 KR fallback 정책을 사용한다")
fun shouldFallbackToKrWhenRequestContextIsMissing() {
RequestContextHolder.resetRequestAttributes()
val member = createMember(id = 202L, countryCode = "US")
assertEquals("KR", resolveCountryCodeByPolicy(member))
assertFalse(isAdultVisibleByPolicy(member, isAdultContentVisible = true))
}
private fun setRequestCountry(countryCode: String?) {
val request = MockHttpServletRequest()
if (countryCode != null) {
request.addHeader("CloudFront-Viewer-Country", countryCode)
}
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}

View File

@@ -0,0 +1,468 @@
package kr.co.vividnext.sodalive.member.contentpreference
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.auth.Auth
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.cache.Cache
import org.springframework.cache.CacheManager
import org.springframework.dao.DataIntegrityViolationException
import java.time.LocalDateTime
import java.util.Optional
class MemberContentPreferenceServiceTest {
private lateinit var repository: MemberContentPreferenceRepository
private lateinit var memberRepository: MemberRepository
private lateinit var countryContext: CountryContext
private lateinit var cacheManager: CacheManager
private lateinit var recommendLiveCache: Cache
private lateinit var service: MemberContentPreferenceService
@BeforeEach
fun setup() {
repository = mock()
memberRepository = mock()
countryContext = CountryContext()
cacheManager = mock()
recommendLiveCache = mock()
Mockito.`when`(cacheManager.getCache("cache_ttl_3_hours")).thenReturn(recommendLiveCache)
service = MemberContentPreferenceService(
repository = repository,
memberRepository = memberRepository,
countryContext = countryContext,
cacheManager = cacheManager
)
}
@Test
@DisplayName("회원 ID 강제 매핑(KR)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedKrMappingFirst() {
val member = createMember(id = 16L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(16L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
}
@Test
@DisplayName("회원 ID 강제 매핑(JP)이 헤더보다 우선 적용된다")
fun shouldResolveCountryCodeByForcedJapanMappingFirst() {
val member = createMember(id = 2L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(2L)).thenReturn(preference)
val result = service.getStoredPreference(member)
assertEquals("JP", result.countryCode)
}
@Test
@DisplayName("강제 매핑 대상이 아니면 접속 국가 헤더를 사용하고 헤더가 없으면 KR로 fallback 한다")
fun shouldResolveCountryCodeWithHeaderAndFallback() {
val member = createMember(id = 100L)
val preference = createPreference(member)
Mockito.`when`(repository.findByMemberId(100L)).thenReturn(preference)
countryContext.setCountryCode("JP")
val fromHeader = service.getStoredPreference(member)
assertEquals("JP", fromHeader.countryCode)
countryContext.setCountryCode(null)
val fromFallback = service.getStoredPreference(member)
assertEquals("KR", fromFallback.countryCode)
}
@Test
@DisplayName("한국 + 본인인증 미완료는 전달값으로 저장 갱신하지 않는다")
fun shouldNotApplyRequestValuesForKoreaWithoutAuth() {
val member = createMember(id = 1700L)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1700L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertFalse(result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("해외 + 본인인증 미완료는 전달값을 그대로 저장한다")
fun shouldApplyRequestValuesForNonKoreaWithoutAuth() {
val member = createMember(id = 1000L)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(1000L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("US", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
}
@Test
@DisplayName("한국 + 본인인증 완료는 전달값을 저장한다")
fun shouldApplyRequestValuesForKoreaWithAuth() {
val member = createMember(id = 1701L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(1701L)).thenReturn(preference)
val result = service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.FEMALE
)
assertEquals("KR", result.countryCode)
assertTrue(result.isAdultContentVisible)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("필드별 변경 시 changedAt은 변경된 필드만 갱신된다")
fun shouldUpdateOnlyChangedFieldTimestamp() {
val member = createMember(id = 3000L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(3000L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
assertTrue(preference.adultContentVisibilityChangedAt.isAfter(baselineTime))
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("contentType만 변경하면 contentTypeChangedAt만 갱신된다")
fun shouldUpdateOnlyContentTypeChangedAtWhenContentTypeChanges() {
val member = createMember(id = 18L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(18L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = false,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertTrue(preference.contentTypeChangedAt.isAfter(baselineTime))
}
@Test
@DisplayName("동일값 재저장 시 changedAt은 갱신되지 않는다")
fun shouldNotUpdateChangedAtWhenValuesAreSame() {
val member = createMember(id = 19L, withAuth = true)
val baselineTime = LocalDateTime.of(2025, 1, 1, 0, 0)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.MALE,
adultContentVisibilityChangedAt = baselineTime,
contentTypeChangedAt = baselineTime
)
preference.member = member
countryContext.setCountryCode("KR")
Mockito.`when`(repository.findByMemberId(19L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.MALE
)
assertEquals(baselineTime, preference.adultContentVisibilityChangedAt)
assertEquals(baselineTime, preference.contentTypeChangedAt)
}
@Test
@DisplayName("getStoredPreference 호출 시 row가 없으면 기본값 row를 생성한다")
fun shouldCreateDefaultPreferenceWhenRowIsMissing() {
val member = createMember(id = 20L)
countryContext.setCountryCode(null)
val storedPreference = createPreference(member)
Mockito.`when`(repository.findByMemberId(20L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(20L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(20L)).thenReturn(null)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenReturn(storedPreference)
val result = service.getStoredPreference(member)
assertEquals("KR", result.countryCode)
assertEquals(storedPreference.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(ContentType.ALL, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("초기 row 생성 경쟁 시 잠금 이후 재조회한 row를 반환한다")
fun shouldReturnReloadedPreferenceWhenRowIsCreatedByAnotherTransactionAfterLock() {
val member = createMember(id = 26L)
val existing = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(26L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(26L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(26L)).thenReturn(existing)
val result = service.getStoredPreference(member)
assertEquals(existing.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(existing.contentType, result.contentType)
Mockito.verify(repository, Mockito.never()).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("동시 insert 충돌 발생 시 저장된 row를 재조회해 반환한다")
fun shouldReturnStoredRowWhenDuplicateInsertOccurs() {
val member = createMember(id = 27L)
val stored = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(27L)).thenReturn(null)
Mockito.`when`(memberRepository.findByIdForUpdate(27L)).thenReturn(member)
Mockito.`when`(repository.findByMemberIdForUpdate(27L)).thenReturn(null, stored)
Mockito.`when`(repository.saveAndFlush(Mockito.any(MemberContentPreference::class.java)))
.thenThrow(DataIntegrityViolationException("duplicate"))
val result = service.getStoredPreference(member)
assertEquals(stored.isAdultContentVisible, result.isAdultContentVisible)
assertEquals(stored.contentType, result.contentType)
Mockito.verify(repository).saveAndFlush(Mockito.any(MemberContentPreference::class.java))
}
@Test
@DisplayName("직접 설정으로 저장값이 변경되면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByUpdatePreference() {
val member = createMember(id = 30L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(30L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheEvicted(30L)
}
@Test
@DisplayName("직접 설정 값이 동일하면 추천 라이브 캐시를 무효화하지 않는다")
fun shouldNotEvictRecommendLiveCacheWhenPreferenceIsUnchanged() {
val member = createMember(id = 31L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = true,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(1),
contentTypeChangedAt = LocalDateTime.now().minusDays(1)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(31L)).thenReturn(preference)
service.updatePreference(
member = member,
isAdultContentVisible = true,
contentType = ContentType.ALL
)
verifyRecommendLiveCacheNotEvicted(31L)
}
@Test
@DisplayName("authVerify 연동으로 성인 노출이 true로 바뀌면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenMarkAdultVisibleAfterAuthVerifyChangesValue() {
val member = createMember(id = 32L)
val preference = createPreference(member)
Mockito.`when`(memberRepository.findById(32L)).thenReturn(Optional.of(member))
Mockito.`when`(repository.findByMemberId(32L)).thenReturn(preference)
service.markAdultVisibleAfterAuthVerify(32L)
verifyRecommendLiveCacheEvicted(32L)
}
@Test
@DisplayName("contentType 미전달 조회는 기존 contentType을 유지한다")
fun shouldKeepStoredContentTypeWhenContentTypeIsNotProvided() {
val member = createMember(id = 21L, withAuth = true)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.FEMALE,
adultContentVisibilityChangedAt = LocalDateTime.now().minusDays(2),
contentTypeChangedAt = LocalDateTime.now().minusDays(2)
)
preference.member = member
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(21L)).thenReturn(preference)
val result = service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = null
)
assertEquals(ContentType.FEMALE, result.contentType)
assertTrue(result.isAdultContentVisible)
}
@Test
@DisplayName("legacy 조회 파라미터로 저장값이 바뀌면 추천 라이브 캐시를 무효화한다")
fun shouldEvictRecommendLiveCacheWhenPreferenceChangesByLegacyResolveForQuery() {
val member = createMember(id = 25L, withAuth = true)
val preference = createPreference(member)
countryContext.setCountryCode("US")
Mockito.`when`(repository.findByMemberId(25L)).thenReturn(preference)
service.resolveForQuery(
member = member,
isAdultContentVisible = true,
contentType = null
)
verifyRecommendLiveCacheEvicted(25L)
}
@Test
@DisplayName("한국/해외 조회 정책은 인증 여부와 국가코드에 따라 다르게 계산된다")
fun shouldCalculateIsAdultByCountryPolicy() {
val noAuthMember = createMember(id = 22L, withAuth = false)
val authMember = createMember(id = 23L, withAuth = true)
assertFalse(service.calculateIsAdultForQuery(noAuthMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(authMember, "KR", true))
assertTrue(service.calculateIsAdultForQuery(noAuthMember, "US", true))
}
@Test
@DisplayName("직접 설정 API 입력이 모두 누락되면 예외를 발생시킨다")
fun shouldThrowWhenAllPreferenceFieldsAreMissing() {
val member = createMember(id = 24L, withAuth = true)
val exception = assertThrows(SodaException::class.java) {
service.updatePreference(
member = member,
isAdultContentVisible = null,
contentType = null
)
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createPreference(member: Member): MemberContentPreference {
val now = LocalDateTime.now().minusDays(1)
val preference = MemberContentPreference(
isAdultContentVisible = false,
contentType = ContentType.ALL,
adultContentVisibilityChangedAt = now,
contentTypeChangedAt = now
)
preference.member = member
return preference
}
private fun createMember(id: Long, withAuth: Boolean = false): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
)
member.id = id
if (withAuth) {
val auth = Auth(
name = "홍길동",
birth = "19900101",
uniqueCi = "unique-$id",
di = "di-$id",
gender = 1
)
auth.member = member
}
return member
}
private fun verifyRecommendLiveCacheEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache).evict("getRecommendLive:$memberId")
}
private fun verifyRecommendLiveCacheNotEvicted(memberId: Long) {
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:false")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId:true")
Mockito.verify(recommendLiveCache, Mockito.never()).evict("getRecommendLive:$memberId")
}
private inline fun <reified T> mock(): T {
return Mockito.mock(T::class.java)
}
}

View File

@@ -0,0 +1,159 @@
package kr.co.vividnext.sodalive.search
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Member
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class SearchServiceTest {
private val repository: SearchRepository = Mockito.mock(SearchRepository::class.java)
private val service = SearchService(repository)
@Test
@DisplayName("콘텐츠 검색은 전달받은 isAdult 값을 그대로 사용한다")
fun shouldUseProvidedIsAdultForContentSearch() {
val member = createMember(id = 101L, countryCode = "KR")
val contentItem = SearchResponseItem(
id = 10L,
imageUrl = "https://cdn.test/content.png",
title = "title",
nickname = "creator"
)
Mockito.`when`(
repository.searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
).thenReturn(1)
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 10
)
).thenReturn(listOf(contentItem))
val result = service.searchContentList(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member,
offset = 0,
limit = 10
)
assertEquals(1, result.totalCount)
assertEquals(SearchResponseType.CONTENT, result.items.first().type)
Mockito.verify(repository).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL
)
Mockito.verify(repository, Mockito.never()).searchContentTotalCount(
keyword = "keyword",
memberId = member.id!!,
isAdult = false,
contentType = ContentType.ALL
)
}
@Test
@DisplayName("통합 검색은 전달받은 isAdult 값으로 콘텐츠/시리즈 조회를 수행한다")
fun shouldUseProvidedIsAdultForUnifiedSearch() {
val member = createMember(id = 102L, countryCode = "KR")
val creatorItem = SearchResponseItem(
id = 20L,
imageUrl = "https://cdn.test/creator.png",
title = "creator",
nickname = "creator"
)
val contentItem = SearchResponseItem(
id = 21L,
imageUrl = "https://cdn.test/content.png",
title = "content",
nickname = "creator"
)
val seriesItem = SearchResponseItem(
id = 22L,
imageUrl = "https://cdn.test/series.png",
title = "series",
nickname = "creator"
)
Mockito.`when`(
repository.searchCreatorList(
keyword = "keyword",
memberId = member.id!!,
offset = 0,
limit = 3
)
).thenReturn(listOf(creatorItem))
Mockito.`when`(
repository.searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(contentItem))
Mockito.`when`(
repository.searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
).thenReturn(listOf(seriesItem))
val result = service.searchUnified(
keyword = "keyword",
isAdult = true,
contentType = ContentType.ALL,
member = member
)
assertEquals(SearchResponseType.CREATOR, result.creatorList.first().type)
assertEquals(SearchResponseType.CONTENT, result.contentList.first().type)
assertEquals(SearchResponseType.SERIES, result.seriesList.first().type)
Mockito.verify(repository).searchContentList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
Mockito.verify(repository).searchSeriesList(
keyword = "keyword",
memberId = member.id!!,
isAdult = true,
contentType = ContentType.ALL,
offset = 0,
limit = 3
)
}
private fun createMember(id: Long, countryCode: String?): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id"
).apply {
this.id = id
this.countryCode = countryCode
}
}
}