From 3ac6aeaf9d0b9745c0a025d195c8ea06ed30a10b Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 16 Mar 2026 18:07:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-community):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...16_크리에이터커뮤니티게시물고정기능추가.md | 14 ++++ .../creatorCommunity/CreatorCommunity.kt | 9 ++- .../CreatorCommunityController.kt | 16 +++++ .../CreatorCommunityRepository.kt | 19 +++-- .../CreatorCommunityService.kt | 28 ++++++++ .../GetCommunityPostListResponse.kt | 1 + .../SelectCommunityPostResponse.kt | 4 +- .../UpdateCommunityPostFixedRequest.kt | 6 ++ .../sodalive/i18n/SodaMessageSource.kt | 5 ++ .../CreatorCommunityServiceTest.kt | 69 +++++++++++++++++++ 10 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 docs/20260316_크리에이터커뮤니티게시물고정기능추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/UpdateCommunityPostFixedRequest.kt diff --git a/docs/20260316_크리에이터커뮤니티게시물고정기능추가.md b/docs/20260316_크리에이터커뮤니티게시물고정기능추가.md new file mode 100644 index 00000000..612d57e6 --- /dev/null +++ b/docs/20260316_크리에이터커뮤니티게시물고정기능추가.md @@ -0,0 +1,14 @@ +- [x] 크리에이터 커뮤니티 게시물 고정/고정해제 API 경로 및 요청 스펙을 정의하고 반영한다. +- [x] 게시물 엔티티에 고정 상태와 고정 시각(또는 순서) 정보를 저장할 수 있도록 반영한다. +- [x] 동일 크리에이터 기준 고정 게시물 최대 3개 제한 검증을 추가하고, 초과 시 예외를 발생시킨다. +- [x] 커뮤니티 게시물 목록 정렬을 고정 우선, 최근 고정 우선, 기존 최신순 우선순위로 반영한다. +- [x] 고정/해제 및 3개 초과 예외, 정렬 우선순위를 검증하는 테스트를 추가/수정한다. +- [x] 검증 결과(무엇/왜/어떻게)를 문서 하단에 기록한다. + +--- + +### 1차 구현 검증 기록 + +- 무엇을: 크리에이터 커뮤니티 게시물 고정/해제 API, 최대 3개 제한 예외, 고정 우선 정렬 반영 여부를 검증했다. +- 왜: 요청된 기능 요구사항(고정 가능 개수 제한, 최근 고정 우선 노출, 고정 해제)을 코드/테스트 기준으로 충족하는지 확인하기 위해서다. +- 어떻게: `./gradlew test --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest"`를 실행했고, 총 5개 테스트(신규 3개 포함)가 모두 성공했다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt index dd57df8b..f3b62f66 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem import kr.co.vividnext.sodalive.member.Member +import java.time.LocalDateTime import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType @@ -10,7 +11,7 @@ import javax.persistence.JoinColumn import javax.persistence.ManyToOne @Entity -data class CreatorCommunity( +class CreatorCommunity( @Column(columnDefinition = "TEXT", nullable = false) var content: String, var price: Int, @@ -20,7 +21,10 @@ data class CreatorCommunity( var audioPath: String? = null, @Column(nullable = true) var imagePath: String? = null, - var isActive: Boolean = true + var isActive: Boolean = true, + var isFixed: Boolean = false, + @Column(nullable = true) + var fixedAt: LocalDateTime? = null ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) @@ -55,6 +59,7 @@ data class CreatorCommunity( dateUtc = dateUtc, isCommentAvailable = isCommentAvailable, isAdult = false, + isFixed = isFixed, isLike = isLike, existOrdered = existOrdered, likeCount = likeCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt index dd60eaff..a8b7b28e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt @@ -68,6 +68,22 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { ) } + @PutMapping("/fixed") + @PreAuthorize("hasRole('CREATOR')") + fun updateCommunityPostFixed( + @RequestBody request: UpdateCommunityPostFixedRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok( + service.updateCommunityPostFixed( + request = request, + member = member + ) + ) + } + @GetMapping fun getCommunityPostList( @RequestParam creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt index f10b3680..616c25f3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt @@ -11,7 +11,9 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime -interface CreatorCommunityRepository : JpaRepository, CreatorCommunityQueryRepository +interface CreatorCommunityRepository : JpaRepository, CreatorCommunityQueryRepository { + fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long +} interface CreatorCommunityQueryRepository { fun findByIdAndMemberId(id: Long, memberId: Long): CreatorCommunity? @@ -71,7 +73,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact creatorCommunity.createdAt, creatorCommunity.isCommentAvailable, creatorCommunity.price, - creatorCommunity.isAdult + creatorCommunity.isAdult, + creatorCommunity.isFixed ) ) .from(creatorCommunity) @@ -89,7 +92,11 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact .where(where) .offset(offset) .limit(limit) - .orderBy(creatorCommunity.createdAt.desc()) + .orderBy( + creatorCommunity.isFixed.desc(), + creatorCommunity.fixedAt.desc().nullsLast(), + creatorCommunity.createdAt.desc() + ) .fetch() } @@ -158,7 +165,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact creatorCommunity.createdAt, creatorCommunity.isCommentAvailable, creatorCommunity.price, - creatorCommunity.isAdult + creatorCommunity.isAdult, + creatorCommunity.isFixed ) ) .from(creatorCommunity) @@ -190,7 +198,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact creatorCommunity.createdAt, creatorCommunity.isCommentAvailable, creatorCommunity.price, - creatorCommunity.isAdult + creatorCommunity.isAdult, + creatorCommunity.isFixed ) ) .from(creatorCommunity) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 5a07bf67..da3d4c1f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -33,6 +33,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile +import java.time.LocalDateTime import java.time.ZoneId @Service @@ -158,6 +159,11 @@ class CreatorCommunityService( if (request.isActive != null) { post.isActive = request.isActive + + if (!post.isActive) { + post.isFixed = false + post.fixedAt = null + } } if (postImage != null) { @@ -179,6 +185,28 @@ class CreatorCommunityService( } } + @Transactional + fun updateCommunityPostFixed(request: UpdateCommunityPostFixedRequest, member: Member) { + val post = repository.findByIdAndMemberId(id = request.postId, memberId = member.id!!) + ?: throw SodaException(messageKey = "common.error.invalid_request") + + if (request.isFixed) { + if (!post.isFixed) { + val fixedPostCount = repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(member.id!!) + + if (fixedPostCount >= 3) { + throw SodaException(messageKey = "creator.community.max_fixed_post_count") + } + } + + post.isFixed = true + post.fixedAt = LocalDateTime.now() + } else { + post.isFixed = false + post.fixedAt = null + } + } + fun getCommunityPostList( creatorId: Long, memberId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt index 7153f809..ddb279ce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt @@ -16,6 +16,7 @@ data class GetCommunityPostListResponse @QueryProjection constructor( val dateUtc: String, val isCommentAvailable: Boolean, val isAdult: Boolean, + val isFixed: Boolean, val isLike: Boolean, val existOrdered: Boolean, val likeCount: Int, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt index df6c657a..6737f1c2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt @@ -15,7 +15,8 @@ data class SelectCommunityPostResponse @QueryProjection constructor( val date: LocalDateTime, val isCommentAvailable: Boolean, val price: Int, - val isAdult: Boolean + val isAdult: Boolean, + val isFixed: Boolean ) { fun toCommunityPostListResponse( imageHost: String, @@ -61,6 +62,7 @@ data class SelectCommunityPostResponse @QueryProjection constructor( dateUtc = dateUtc, isCommentAvailable = isCommentAvailable, isAdult = isAdult, + isFixed = isFixed, isLike = isLike, existOrdered = existOrdered, likeCount = likeCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/UpdateCommunityPostFixedRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/UpdateCommunityPostFixedRequest.kt new file mode 100644 index 00000000..e9863958 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/UpdateCommunityPostFixedRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity + +data class UpdateCommunityPostFixedRequest( + val postId: Long, + val isFixed: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index dcfdb908..8ee077b8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -2297,6 +2297,11 @@ class SodaMessageSource { Lang.EN to "Invalid access.\nPlease check and try again.", Lang.JA to "不正なアクセスです。\n恐れ入りますが、確認後再度お試しください。" ), + "creator.community.max_fixed_post_count" to mapOf( + Lang.KO to "최대 3개까지 고정 가능합니다.", + Lang.EN to "You can pin up to 3 posts.", + Lang.JA to "固定できる投稿は最大3件までです。" + ), "creator.community.blocked_access" to mapOf( Lang.KO to "%s님의 요청으로 접근이 제한됩니다.", Lang.EN to "Access is restricted at %s's request.", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index f3f53dae..44ba0306 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.can.payment.CanPaymentService 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.CreatorCommunityLikeRepository @@ -18,12 +19,18 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMemberRepository 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.assertNull +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.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher +import java.time.LocalDateTime import java.util.Optional class CreatorCommunityServiceTest { @@ -121,6 +128,68 @@ class CreatorCommunityServiceTest { Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any()) } + @Test + @DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다") + fun shouldThrowExceptionWhenPinCountExceedsLimit() { + val creator = createMember(id = 55L, role = MemberRole.CREATOR, nickname = "creator") + val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false) + post.id = 501L + post.member = creator + + Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post) + Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(3L) + + val exception = assertThrows(SodaException::class.java) { + service.updateCommunityPostFixed( + request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true), + member = creator + ) + } + + assertEquals("creator.community.max_fixed_post_count", exception.messageKey) + } + + @Test + @DisplayName("고정 요청 시 3개 미만이면 게시물이 고정된다") + fun shouldPinPostWhenFixedPostCountIsUnderLimit() { + val creator = createMember(id = 66L, role = MemberRole.CREATOR, nickname = "creator") + val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false) + post.id = 601L + post.member = creator + + Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post) + Mockito.`when`(repository.countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(creator.id!!)).thenReturn(2L) + + service.updateCommunityPostFixed( + request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = true), + member = creator + ) + + assertTrue(post.isFixed) + assertNotNull(post.fixedAt) + } + + @Test + @DisplayName("고정 해제 요청 시 게시물이 고정 해제된다") + fun shouldUnfixPostWhenIsFixedIsFalse() { + val creator = createMember(id = 77L, role = MemberRole.CREATOR, nickname = "creator") + val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false) + post.id = 701L + post.member = creator + post.isFixed = true + post.fixedAt = LocalDateTime.now() + + Mockito.`when`(repository.findByIdAndMemberId(post.id!!, creator.id!!)).thenReturn(post) + + service.updateCommunityPostFixed( + request = UpdateCommunityPostFixedRequest(postId = post.id!!, isFixed = false), + member = creator + ) + + assertFalse(post.isFixed) + assertNull(post.fixedAt) + } + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { val member = Member( email = "$nickname@test.com",