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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user