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.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,

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

View File

@@ -11,7 +11,9 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, CreatorCommunityQueryRepository
interface CreatorCommunityRepository : JpaRepository<CreatorCommunity, Long>, 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)

View File

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

View File

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

View File

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

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.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.",

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.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",