feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다

This commit is contained in:
2026-03-16 18:07:36 +09:00
parent 5d7bb8590f
commit 3ac6aeaf9d
10 changed files with 163 additions and 8 deletions

View File

@@ -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개 포함)가 모두 성공했다.

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import java.time.LocalDateTime
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.FetchType import javax.persistence.FetchType
@@ -10,7 +11,7 @@ import javax.persistence.JoinColumn
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
@Entity @Entity
data class CreatorCommunity( class CreatorCommunity(
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var content: String, var content: String,
var price: Int, var price: Int,
@@ -20,7 +21,10 @@ data class CreatorCommunity(
var audioPath: String? = null, var audioPath: String? = null,
@Column(nullable = true) @Column(nullable = true)
var imagePath: String? = null, var imagePath: String? = null,
var isActive: Boolean = true var isActive: Boolean = true,
var isFixed: Boolean = false,
@Column(nullable = true)
var fixedAt: LocalDateTime? = null
) : BaseEntity() { ) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false) @JoinColumn(name = "member_id", nullable = false)
@@ -55,6 +59,7 @@ data class CreatorCommunity(
dateUtc = dateUtc, dateUtc = dateUtc,
isCommentAvailable = isCommentAvailable, isCommentAvailable = isCommentAvailable,
isAdult = false, isAdult = false,
isFixed = isFixed,
isLike = isLike, isLike = isLike,
existOrdered = existOrdered, existOrdered = existOrdered,
likeCount = likeCount, likeCount = likeCount,

View File

@@ -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 @GetMapping
fun getCommunityPostList( fun getCommunityPostList(
@RequestParam creatorId: Long, @RequestParam creatorId: Long,

View File

@@ -11,7 +11,9 @@ import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository {
fun countByMemberIdAndIsFixedIsTrueAndIsActiveIsTrue(memberId: Long): Long
}
interface CreatorCommunityQueryRepository { interface CreatorCommunityQueryRepository {
fun findByIdAndMemberId(id: Long, memberId: Long): CreatorCommunity? fun findByIdAndMemberId(id: Long, memberId: Long): CreatorCommunity?
@@ -71,7 +73,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt, creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable, creatorCommunity.isCommentAvailable,
creatorCommunity.price, creatorCommunity.price,
creatorCommunity.isAdult creatorCommunity.isAdult,
creatorCommunity.isFixed
) )
) )
.from(creatorCommunity) .from(creatorCommunity)
@@ -89,7 +92,11 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
.where(where) .where(where)
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.orderBy(creatorCommunity.createdAt.desc()) .orderBy(
creatorCommunity.isFixed.desc(),
creatorCommunity.fixedAt.desc().nullsLast(),
creatorCommunity.createdAt.desc()
)
.fetch() .fetch()
} }
@@ -158,7 +165,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt, creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable, creatorCommunity.isCommentAvailable,
creatorCommunity.price, creatorCommunity.price,
creatorCommunity.isAdult creatorCommunity.isAdult,
creatorCommunity.isFixed
) )
) )
.from(creatorCommunity) .from(creatorCommunity)
@@ -190,7 +198,8 @@ class CreatorCommunityQueryRepositoryImpl(private val queryFactory: JPAQueryFact
creatorCommunity.createdAt, creatorCommunity.createdAt,
creatorCommunity.isCommentAvailable, creatorCommunity.isCommentAvailable,
creatorCommunity.price, creatorCommunity.price,
creatorCommunity.isAdult creatorCommunity.isAdult,
creatorCommunity.isFixed
) )
) )
.from(creatorCommunity) .from(creatorCommunity)

View File

@@ -33,6 +33,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@Service @Service
@@ -158,6 +159,11 @@ class CreatorCommunityService(
if (request.isActive != null) { if (request.isActive != null) {
post.isActive = request.isActive post.isActive = request.isActive
if (!post.isActive) {
post.isFixed = false
post.fixedAt = null
}
} }
if (postImage != 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( fun getCommunityPostList(
creatorId: Long, creatorId: Long,
memberId: Long, memberId: Long,

View File

@@ -16,6 +16,7 @@ data class GetCommunityPostListResponse @QueryProjection constructor(
val dateUtc: String, val dateUtc: String,
val isCommentAvailable: Boolean, val isCommentAvailable: Boolean,
val isAdult: Boolean, val isAdult: Boolean,
val isFixed: Boolean,
val isLike: Boolean, val isLike: Boolean,
val existOrdered: Boolean, val existOrdered: Boolean,
val likeCount: Int, val likeCount: Int,

View File

@@ -15,7 +15,8 @@ data class SelectCommunityPostResponse @QueryProjection constructor(
val date: LocalDateTime, val date: LocalDateTime,
val isCommentAvailable: Boolean, val isCommentAvailable: Boolean,
val price: Int, val price: Int,
val isAdult: Boolean val isAdult: Boolean,
val isFixed: Boolean
) { ) {
fun toCommunityPostListResponse( fun toCommunityPostListResponse(
imageHost: String, imageHost: String,
@@ -61,6 +62,7 @@ data class SelectCommunityPostResponse @QueryProjection constructor(
dateUtc = dateUtc, dateUtc = dateUtc,
isCommentAvailable = isCommentAvailable, isCommentAvailable = isCommentAvailable,
isAdult = isAdult, isAdult = isAdult,
isFixed = isFixed,
isLike = isLike, isLike = isLike,
existOrdered = existOrdered, existOrdered = existOrdered,
likeCount = likeCount, likeCount = likeCount,

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
data class UpdateCommunityPostFixedRequest(
val postId: Long,
val isFixed: Boolean
)

View File

@@ -2297,6 +2297,11 @@ class SodaMessageSource {
Lang.EN to "Invalid access.\nPlease check and try again.", Lang.EN to "Invalid access.\nPlease check and try again.",
Lang.JA to "不正なアクセスです。\n恐れ入りますが、確認後再度お試しください。" 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( "creator.community.blocked_access" to mapOf(
Lang.KO to "%s님의 요청으로 접근이 제한됩니다.", Lang.KO to "%s님의 요청으로 접근이 제한됩니다.",
Lang.EN to "Access is restricted at %s's request.", Lang.EN to "Access is restricted at %s's request.",

View File

@@ -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.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanRepository 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.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository 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.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals 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.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
class CreatorCommunityServiceTest { class CreatorCommunityServiceTest {
@@ -121,6 +128,68 @@ class CreatorCommunityServiceTest {
Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any()) 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 { private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",