From 8300b1875c12b03e832d46449a44713891c931a7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Jun 2026 10:19:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=ED=8C=94=EB=A1=9C=EC=9A=B0=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../alter-existing-tables.sql | 60 ++++++ .../member/following/CreatorFollowing.kt | 12 +- .../RecommendedCreatorFollowService.kt | 51 ++++++ .../RecommendedCreatorFollowServiceTest.kt | 172 ++++++++++++++++++ 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 docs/20260529_메인_홈_추천_API/alter-existing-tables.sql create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt diff --git a/docs/20260529_메인_홈_추천_API/alter-existing-tables.sql b/docs/20260529_메인_홈_추천_API/alter-existing-tables.sql new file mode 100644 index 00000000..78e7fc9a --- /dev/null +++ b/docs/20260529_메인_홈_추천_API/alter-existing-tables.sql @@ -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); diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt index 51653934..a331e7c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowing.kt @@ -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() { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt new file mode 100644 index 00000000..7e16b326 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowService.kt @@ -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) { + 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) + } + ) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt new file mode 100644 index 00000000..e62de7ba --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/RecommendedCreatorFollowServiceTest.kt @@ -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 + } + ) + } +}