feat(creator-community): 커뮤니티 게시물 고정 기능을 추가한다
This commit is contained in:
14
docs/20260316_크리에이터커뮤니티게시물고정기능추가.md
Normal file
14
docs/20260316_크리에이터커뮤니티게시물고정기능추가.md
Normal 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개 포함)가 모두 성공했다.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
|
||||
|
||||
data class UpdateCommunityPostFixedRequest(
|
||||
val postId: Long,
|
||||
val isFixed: Boolean
|
||||
)
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user