feat(recommend): 추천 크리에이터 동시 팔로우 서비스를 추가한다
This commit is contained in:
60
docs/20260529_메인_홈_추천_API/alter-existing-tables.sql
Normal file
60
docs/20260529_메인_홈_추천_API/alter-existing-tables.sql
Normal file
@@ -0,0 +1,60 @@
|
||||
-- Phase 5: 추천 크리에이터 동시 팔로우 중복 방지 운영 DB 반영 SQL
|
||||
-- 목적: creator_following 테이블의 동일 회원/크리에이터 중복 row를 정리하고 유니크 제약을 추가한다.
|
||||
-- 주의: 운영 반영 전 아래 중복 조회 결과를 검토하고, 삭제 대상 row가 운영 정책상 보존 대상인지 확인한다.
|
||||
|
||||
-- 1. 중복 데이터 사전 점검
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
count(*) as duplicate_count,
|
||||
group_concat(id order by id asc) as duplicate_ids
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1;
|
||||
|
||||
-- 2. 중복 row 정리
|
||||
-- 동일 member_id/creator_id 조합에서 가장 작은 id 1개만 유지한다.
|
||||
-- 유지 row는 중복 row 중 하나라도 활성 상태였으면 활성 상태로 보정한다.
|
||||
update creator_following keep_cf
|
||||
join (
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
min(id) as keep_id,
|
||||
max(case when is_active = true then 1 else 0 end) as any_active,
|
||||
max(case when is_notify = true then 1 else 0 end) as any_notify
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1
|
||||
) duplicate_cf on keep_cf.id = duplicate_cf.keep_id
|
||||
set
|
||||
keep_cf.is_active = duplicate_cf.any_active = 1,
|
||||
keep_cf.is_notify = duplicate_cf.any_notify = 1;
|
||||
|
||||
delete duplicate_cf
|
||||
from creator_following duplicate_cf
|
||||
join (
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
min(id) as keep_id
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1
|
||||
) keep_cf on duplicate_cf.member_id = keep_cf.member_id
|
||||
and duplicate_cf.creator_id = keep_cf.creator_id
|
||||
and duplicate_cf.id <> keep_cf.keep_id;
|
||||
|
||||
-- 3. 중복 정리 결과 재확인: 결과가 없어야 한다.
|
||||
select
|
||||
member_id,
|
||||
creator_id,
|
||||
count(*) as duplicate_count,
|
||||
group_concat(id order by id asc) as duplicate_ids
|
||||
from creator_following
|
||||
group by member_id, creator_id
|
||||
having count(*) > 1;
|
||||
|
||||
-- 4. 유니크 제약 추가
|
||||
alter table creator_following
|
||||
add constraint uk_creator_following_member_creator unique (member_id, creator_id);
|
||||
@@ -6,9 +6,19 @@ import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.ManyToOne
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
data class CreatorFollowing(
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_creator_following_member_creator",
|
||||
columnNames = ["member_id", "creator_id"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class CreatorFollowing(
|
||||
var isNotify: Boolean = true,
|
||||
var isActive: Boolean = true
|
||||
) : BaseEntity() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class RecommendedCreatorFollowService(
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun followCreators(member: Member, creatorIds: List<Long>) {
|
||||
val distinctCreatorIds = creatorIds.distinct()
|
||||
val creatorById = distinctCreatorIds
|
||||
.filter { it != member.id }
|
||||
.associateWith { creatorId ->
|
||||
memberRepository.findCreatorByIdOrNull(creatorId)
|
||||
?: throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||
}
|
||||
|
||||
distinctCreatorIds.forEach { creatorId ->
|
||||
if (creatorId == member.id) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val existingFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = member.id!!
|
||||
)
|
||||
if (existingFollowing != null) {
|
||||
if (!existingFollowing.isActive) {
|
||||
existingFollowing.isNotify = true
|
||||
existingFollowing.isActive = true
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
|
||||
creatorFollowingRepository.save(
|
||||
CreatorFollowing().apply {
|
||||
this.member = member
|
||||
creator = creatorById.getValue(creatorId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommend.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
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.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
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.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.test.context.ContextConfiguration
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@SpringBootTest
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
class RecommendedCreatorFollowServiceTest @Autowired constructor(
|
||||
private val service: RecommendedCreatorFollowService,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("신규 크리에이터만 팔로우 저장하고 이미 팔로우/본인 id는 서버 내부에서 제외한다")
|
||||
fun shouldFollowOnlyNewCreatorsAndSkipExistingAndSelf() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val newCreator = saveMember("new-creator", MemberRole.CREATOR)
|
||||
val followedCreator = saveMember("followed-creator", MemberRole.CREATOR)
|
||||
saveFollowing(member = member, creator = followedCreator)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.followCreators(
|
||||
member = member,
|
||||
creatorIds = listOf(newCreator.id!!, followedCreator.id!!, member.id!!)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!))
|
||||
assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!))
|
||||
assertEquals(2, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("비활성 팔로우 이력이 있으면 신규 row를 만들지 않고 다시 활성화한다")
|
||||
fun shouldReactivateInactiveFollowingWithoutCreatingDuplicateRow() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("reactivate-creator", MemberRole.CREATOR)
|
||||
val inactiveFollowing = saveFollowing(member = member, creator = creator).apply {
|
||||
isNotify = false
|
||||
isActive = false
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
|
||||
assertNotNull(reactivatedFollowing)
|
||||
assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id)
|
||||
assertTrue(reactivatedFollowing.isNotify)
|
||||
assertTrue(reactivatedFollowing.isActive)
|
||||
assertEquals(1, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이미 활성 팔로우 중이면 알림 설정을 바꾸지 않고 그대로 둔다")
|
||||
fun shouldKeepActiveExistingFollowingNotificationSetting() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("active-creator", MemberRole.CREATOR)
|
||||
val existingFollowing = saveFollowing(member = member, creator = creator).apply {
|
||||
isNotify = false
|
||||
isActive = true
|
||||
}
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.followCreators(member = member, creatorIds = listOf(creator.id!!))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val unchangedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!)
|
||||
assertNotNull(unchangedFollowing)
|
||||
assertEquals(existingFollowing.id, unchangedFollowing!!.id)
|
||||
assertFalse(unchangedFollowing.isNotify)
|
||||
assertTrue(unchangedFollowing.isActive)
|
||||
assertEquals(1, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 회원과 크리에이터 팔로우 row는 중복 저장할 수 없다")
|
||||
fun shouldRejectDuplicateFollowingRowsForSameMemberAndCreator() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val creator = saveMember("duplicate-creator", MemberRole.CREATOR)
|
||||
saveFollowing(member = member, creator = creator)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(DataIntegrityViolationException::class.java) {
|
||||
saveFollowing(member = member, creator = creator)
|
||||
entityManager.flush()
|
||||
}
|
||||
|
||||
assertNotNull(exception)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("존재하지 않는 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
|
||||
fun shouldFailAllAndSaveNothingWhenAnyCreatorIdDoesNotExist() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, 999_999L))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터가 아닌 회원 id가 하나라도 포함되면 전체 실패하고 신규 저장하지 않는다")
|
||||
fun shouldFailAllAndSaveNothingWhenAnyMemberIdIsNotCreator() {
|
||||
val member = saveMember("viewer", MemberRole.USER)
|
||||
val validCreator = saveMember("valid-creator", MemberRole.CREATOR)
|
||||
val nonCreator = saveMember("non-creator", MemberRole.USER)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val exception = assertThrows(SodaException::class.java) {
|
||||
service.followCreators(member = member, creatorIds = listOf(validCreator.id!!, nonCreator.id!!))
|
||||
}
|
||||
|
||||
assertEquals("member.validation.creator_not_found", exception.messageKey)
|
||||
assertEquals(0, creatorFollowingRepository.findAll().size)
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||
return memberRepository.saveAndFlush(
|
||||
Member(
|
||||
email = "$seed@test.com",
|
||||
password = "password",
|
||||
nickname = seed,
|
||||
role = role
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveFollowing(member: Member, creator: Member): CreatorFollowing {
|
||||
return creatorFollowingRepository.saveAndFlush(
|
||||
CreatorFollowing().apply {
|
||||
this.member = member
|
||||
this.creator = creator
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user