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

@@ -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
)