From 1650ed402c4aff06571bac269a6448d3d84ffa32 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 23 Feb 2026 22:46:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(channel-donation):=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=ED=9B=84=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260223_channel_donation_message_ddl.sql | 18 ++ docs/20260223_채널후원기능추가.md | 67 +++++++ .../co/vividnext/sodalive/can/CanService.kt | 32 +++- .../sodalive/can/payment/CanPaymentService.kt | 4 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 1 + .../sodalive/explorer/ExplorerService.kt | 13 ++ .../explorer/GetCreatorProfileResponse.kt | 2 + .../ChannelDonationController.kt | 45 +++++ .../channelDonation/ChannelDonationMessage.kt | 25 +++ .../ChannelDonationMessageRepository.kt | 99 ++++++++++ .../channelDonation/ChannelDonationService.kt | 133 +++++++++++++ .../GetChannelDonationListResponse.kt | 17 ++ .../PostChannelDonationRequest.kt | 9 + .../sodalive/i18n/SodaMessageSource.kt | 10 + .../ChannelDonationControllerTest.kt | 106 +++++++++++ .../ChannelDonationMessageRepositoryTest.kt | 136 ++++++++++++++ .../ChannelDonationServiceTest.kt | 175 ++++++++++++++++++ 17 files changed, 890 insertions(+), 2 deletions(-) create mode 100644 docs/20260223_channel_donation_message_ddl.sql create mode 100644 docs/20260223_채널후원기능추가.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt diff --git a/docs/20260223_channel_donation_message_ddl.sql b/docs/20260223_channel_donation_message_ddl.sql new file mode 100644 index 00000000..58a5d04d --- /dev/null +++ b/docs/20260223_channel_donation_message_ddl.sql @@ -0,0 +1,18 @@ +CREATE TABLE channel_donation_message +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'PK', + member_id BIGINT NOT NULL COMMENT '후원한 유저', + creator_id BIGINT NOT NULL COMMENT '후원 받은 채널 크리에이터', + can INT NOT NULL COMMENT '후원한 캔', + is_secret TINYINT(1) NOT NULL DEFAULT 0 COMMENT '비밀후원 여부(false=0, true=1)', + additional_message TEXT NULL COMMENT '추가 메시지', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', + PRIMARY KEY (id), + KEY idx_channel_donation_message_creator_created_at (creator_id, created_at), + KEY idx_channel_donation_message_member (member_id), + CONSTRAINT fk_channel_donation_message_member + FOREIGN KEY (member_id) REFERENCES member (id), + CONSTRAINT fk_channel_donation_message_creator + FOREIGN KEY (creator_id) REFERENCES member (id) +) COMMENT ='채널 후원 메시지'; diff --git a/docs/20260223_채널후원기능추가.md b/docs/20260223_채널후원기능추가.md new file mode 100644 index 00000000..1cc9c80e --- /dev/null +++ b/docs/20260223_채널후원기능추가.md @@ -0,0 +1,67 @@ +# 채널 후원 기능 추가 작업 계획 + +## 메시지 저장 전략 선택 +- 선택: 기본 메시지는 DB에 저장하지 않고, 후원 이력에는 `can`, `isSecret`, `additionalMessage`를 저장한 뒤 리스트 조회 시 메시지를 생성한다. +- 이유: 일반/비밀 구분과 캔 수 노출 요구를 구조화 필드로 충족할 수 있고, 문구 변경/다국어 확장 시 DB 마이그레이션 없이 대응 가능하다. +- 메시지 생성 규칙: + - 일반 후원: `OO캔을 후원하셨습니다.` + - 비밀 후원: `OO캔을 비밀후원하셨습니다.` + - 추가 메시지 입력 시: 기본 메시지 + `\n` + `"사용자 추가 메시지"` + +- [x] 채널 후원 도메인 모델/저장소 설계 (`ChannelDonationMessage` 성격의 별도 엔티티, creator/sponsor/can/isSecret/additionalMessage/createdAt) +- [x] `CanUsage`에 채널 후원 전용 값 1종 추가 및 영향 범위 정의 (`CanPaymentService`, 사용내역 타이틀 매핑) +- [x] 채널 후원 API 요청/응답 스펙 확정 (필드: `creatorId`, `can`, `isSecret`, `message`, `container`) +- [x] 채널 후원 API 서비스 플로우 설계 (인증/크리에이터 검증 -> 캔 차감 -> 후원 메시지 DB 저장) +- [x] 채널 후원 리스트 API 스펙 확정 (최근 1개월, `createdAt` 내림차순, 페이징) +- [x] 채널 후원 리스트 조회 권한 규칙 반영 + - 크리에이터: 모든 후원 내역 조회 + - 유저: 일반 후원 + 본인이 한 비밀 후원 내역 조회 +- [x] 리스트 응답 메시지 조합 규칙 반영 (일반/비밀 기본 메시지 + 추가 메시지 쌍따옴표 처리) +- [x] `explorer/profile/{id}` 응답 확장 설계 (최근 1개월 채널 후원 내역 최대 5건 포함) +- [x] QueryDSL 조회 조건 확정 (`createdAt >= now().minusMonths(1)`, `orderBy(createdAt.desc(), id.desc())`, `limit 5`) +- [x] 테스트 계획 수립 (서비스 단위 테스트 + 리포지토리 날짜 필터/정렬 테스트 + 컨트롤러 통합 테스트) +- [x] 정산 로직 제외 범위 명시 (정산 비율 변경 작업은 미포함, 채널 후원 기능만 구현) +- [x] 구현 후 검증 계획 확정 (`./gradlew test`, `./gradlew build`, 필요 시 `./gradlew ktlintCheck`) +- [x] 운영 반영용 DDL 파일 추가 (`docs/20260223_channel_donation_message_ddl.sql`) +- [x] 채널 후원 회귀 테스트 구현 + - 서비스: `ChannelDonationServiceTest` + - 리포지토리: `ChannelDonationMessageRepositoryTest` + - 컨트롤러: `ChannelDonationControllerTest` + +## 검증 기록 +- 무엇을: + - 1차 계획 수립: 채널 후원 기능의 API/도메인/조회 범위를 정의하고, 메시지 저장 전략을 선택해 계획 문서로 고정했다. + - 2차 수정: 채널 후원 리스트 API의 조회 권한 규칙(크리에이터 전체 조회, 유저는 일반 후원+본인 비밀 후원 조회)을 계획 항목에 추가했다. + - 3차 구현: 채널 후원 API/리스트 API/Explorer 프로필 확장, `CanUsage.CHANNEL_DONATION`, 메시지 엔티티 저장, 권한별 노출 필터를 구현했다. +- 왜: + - 기존 코드 패턴(Explorer/CanUsage/후원 조회)을 따르는 구현 범위를 먼저 고정해 불필요한 확장과 API 불일치를 방지하기 위해. + - 리스트 조회 시 요청자 역할에 따라 비밀 후원 노출 범위가 달라지므로, 구현 전 권한 규칙을 계획 단계에서 명확히 고정하기 위해. + - 채널 후원은 기존 라이브/콘텐츠 후원과 정산 분리를 위해 별도 `CanUsage`와 별도 메시지 저장소가 필요하고, 프로필 화면에 최근 내역 노출 요구가 있어 Explorer 응답 확장이 필요하기 때문에. +- 어떻게: + - 내부 탐색: `ExplorerController`, `ExplorerService`, `ExplorerQueryRepository`, `CanUsage`, `CanPaymentService`, `LiveRoomService`, `LiveRoomRepository`를 확인했다. + - 병렬 조사: `explore` 2건(`bg_07537536`, `bg_5be8611b`)과 `librarian` 1건(`bg_bfe81033`) 결과를 수집해 근거를 보강했다. + - 추가 확인: `AudioContentCommentRepository`, `CreatorCommunityCommentRepository`, `LiveRoomRepository`의 비밀/본인 공개 조건 패턴(`isSecret.isFalse.or(writerId.eq(memberId))`)을 확인해 문서 규칙에 반영했다. + - 구현 파일: `explorer/profile/channelDonation/*`, `CanUsage.kt`, `CanPaymentService.kt`, `CanService.kt`, `ExplorerService.kt`, `GetCreatorProfileResponse.kt`를 수정/추가했다. + - 검증 명령: + - `./gradlew test` -> 성공 + - `./gradlew build` -> 최초 1회 `GetCreatorProfileResponse.kt` import 정렬 실패(ktlint), 정렬 수정 후 재실행 성공 + - `./gradlew ktlintCheck` -> 성공 + +### 4차 보완(리뷰 지적사항 반영) +- 무엇을: + - 누락됐던 운영 반영용 DDL 파일 `docs/20260223_channel_donation_message_ddl.sql`을 추가했다. + - 채널 후원 회귀 테스트 3종(서비스/리포지토리/컨트롤러)을 신규 추가했다. +- 왜: + - `ddl-auto: validate` 환경에서 신규 엔티티 스키마 누락 시 부팅 실패 위험이 있어 적용 스크립트를 분리 관리해야 했기 때문이다. + - 권한별 비밀후원 노출, 1개월 필터, 정렬/페이징 규칙을 자동 검증해 회귀를 방지하기 위해서다. +- 어떻게: + - 추가 파일: + - `docs/20260223_channel_donation_message_ddl.sql` + - `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt` + - `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt` + - `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt` + - 검증 명령: + - `lsp_diagnostics` -> Kotlin LSP 미설정으로 실행 불가(환경 제약 확인) + - `./gradlew test --tests "*ChannelDonation*"` -> 성공 + - `./gradlew test` -> 성공 + - `./gradlew build` -> 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index bd433c6c..b0b64759 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.time.ZoneId @@ -13,7 +14,8 @@ import java.time.format.DateTimeFormatter @Service class CanService( private val repository: CanRepository, - private val countryContext: CountryContext + private val countryContext: CountryContext, + private val memberRepository: MemberRepository ) { fun getCans(isNotSelectedCurrency: Boolean): List { val currency = if (isNotSelectedCurrency) { @@ -40,7 +42,7 @@ class CanService( timezone: String, container: String ): List { - return repository.getCanUseStatus(member, pageable) + val useCanList = repository.getCanUseStatus(member, pageable) .filter { (it.can + it.rewardCan) > 0 } .filter { when (container) { @@ -66,6 +68,21 @@ class CanService( } } } + + val channelDonationCreatorIds = useCanList + .asSequence() + .filter { it.canUsage == CanUsage.CHANNEL_DONATION } + .mapNotNull { it.useCanCalculates.firstOrNull()?.recipientCreatorId } + .distinct() + .toList() + + val channelDonationCreatorNicknameMap = if (channelDonationCreatorIds.isEmpty()) { + emptyMap() + } else { + memberRepository.findAllById(channelDonationCreatorIds).associate { it.id!! to it.nickname } + } + + return useCanList .map { val title: String = when (it.canUsage) { CanUsage.HEART, CanUsage.DONATION, CanUsage.SPIN_ROULETTE -> { @@ -78,6 +95,17 @@ class CanService( } } + CanUsage.CHANNEL_DONATION -> { + val creatorId = it.useCanCalculates.firstOrNull()?.recipientCreatorId + val creatorNickname = creatorId?.let { id -> channelDonationCreatorNicknameMap[id] } + + if (creatorNickname.isNullOrBlank()) { + "[채널 후원]" + } else { + "[채널 후원] $creatorNickname" + } + } + CanUsage.LIVE -> { "[라이브] ${it.room!!.title}" } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 8a93aaa7..d5de9e6d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -48,6 +48,7 @@ class CanPaymentService( characterId: Long? = null, isSecret: Boolean = false, liveRoom: LiveRoom? = null, + creator: Member? = null, order: Order? = null, audioContent: AudioContent? = null, communityPost: CreatorCommunity? = null, @@ -93,6 +94,9 @@ class CanPaymentService( recipientId = liveRoom.member!!.id!! useCan.room = liveRoom useCan.member = member + } else if (canUsage == CanUsage.CHANNEL_DONATION && creator != null) { + recipientId = creator.id!! + useCan.member = member } else if (canUsage == CanUsage.ORDER_CONTENT && order != null) { recipientId = order.creator!!.id!! useCan.order = order diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 44bcbd55..32259413 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -4,6 +4,7 @@ enum class CanUsage { LIVE, HEART, DONATION, + CHANNEL_DONATION, // 채널 후원 CHANGE_NICKNAME, ORDER_CONTENT, SPIN_ROULETTE, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 49205a9f..55549600 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -17,6 +17,7 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType @@ -51,6 +52,7 @@ class ExplorerService( private val queryRepository: ExplorerQueryRepository, private val cheersRepository: CreatorCheersRepository, private val noticeRepository: ChannelNoticeRepository, + private val channelDonationService: ChannelDonationService, private val communityService: CreatorCommunityService, private val seriesService: ContentSeriesService, @@ -394,6 +396,16 @@ class ExplorerService( limit = 4 ) + val channelDonationList = if (isCreator && !isBlock) { + channelDonationService.getChannelDonationListForProfile( + creatorId = creatorId, + member = member, + limit = 5 + ) + } else { + listOf() + } + // 차단한 크리에이터 인지 체크 val activitySummary = if (isCreator) { // 활동요약 (라이브 횟수, 라이브 시간, 라이브 참여자, 콘텐츠 수) @@ -455,6 +467,7 @@ class ExplorerService( ownedContentCount = ownedContentCount, notice = notice, communityPostList = communityPostList, + channelDonationList = channelDonationList, cheers = cheers, activitySummary = activitySummary, seriesList = seriesList, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt index 083d3e22..50d7665c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCreatorProfileResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.explorer import kr.co.vividnext.sodalive.content.GetAudioContentListItem import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.GetChannelDonationListItem import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse data class GetCreatorProfileResponse( @@ -15,6 +16,7 @@ data class GetCreatorProfileResponse( val ownedContentCount: Long, val notice: String, val communityPostList: List, + val channelDonationList: List, val cheers: GetCheersResponse, val activitySummary: GetCreatorActivitySummary, val seriesList: List, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt new file mode 100644 index 00000000..4f1b69b1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationController.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.Pageable +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/explorer/profile/channel-donation") +class ChannelDonationController( + private val channelDonationService: ChannelDonationService +) { + @PostMapping + fun donate( + @RequestBody request: PostChannelDonationRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok(channelDonationService.donate(request, member)) + } + + @GetMapping + fun getChannelDonationList( + @RequestParam creatorId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + pageable: Pageable + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + ApiResponse.ok( + channelDonationService.getChannelDonationList( + creatorId = creatorId, + member = member, + offset = pageable.offset, + limit = pageable.pageSize.toLong() + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt new file mode 100644 index 00000000..3b2e1002 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class ChannelDonationMessage( + val can: Int, + val isSecret: Boolean = false, + @Column(columnDefinition = "TEXT") + var additionalMessage: String? = null +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_id", nullable = false) + var creator: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt new file mode 100644 index 00000000..dcc5f886 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepository.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface ChannelDonationMessageRepository : + JpaRepository, ChannelDonationMessageQueryRepository + +interface ChannelDonationMessageQueryRepository { + fun getChannelDonationMessageList( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + offset: Long, + limit: Long, + startDateTime: LocalDateTime + ): List + + fun getChannelDonationMessageTotalCount( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ): Int +} + +class ChannelDonationMessageQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : ChannelDonationMessageQueryRepository { + override fun getChannelDonationMessageList( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + offset: Long, + limit: Long, + startDateTime: LocalDateTime + ): List { + val where = whereCondition( + creatorId = creatorId, + memberId = memberId, + isCreator = isCreator, + startDateTime = startDateTime + ) + + return queryFactory + .selectFrom(channelDonationMessage) + .where(where) + .offset(offset) + .limit(limit) + .orderBy( + channelDonationMessage.createdAt.desc(), + channelDonationMessage.id.desc() + ) + .fetch() + } + + override fun getChannelDonationMessageTotalCount( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ): Int { + val where = whereCondition( + creatorId = creatorId, + memberId = memberId, + isCreator = isCreator, + startDateTime = startDateTime + ) + + return queryFactory + .select(channelDonationMessage.id) + .from(channelDonationMessage) + .where(where) + .fetch() + .size + } + + private fun whereCondition( + creatorId: Long, + memberId: Long, + isCreator: Boolean, + startDateTime: LocalDateTime + ) = channelDonationMessage.creator.id.eq(creatorId) + .and(channelDonationMessage.createdAt.goe(startDateTime)) + .let { + if (isCreator) { + it + } else { + it.and( + channelDonationMessage.isSecret.isFalse + .or(channelDonationMessage.member.id.eq(memberId)) + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt new file mode 100644 index 00000000..d6cba758 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationService.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +class ChannelDonationService( + private val canPaymentService: CanPaymentService, + private val memberRepository: MemberRepository, + private val channelDonationMessageRepository: ChannelDonationMessageRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + @Transactional + fun donate(request: PostChannelDonationRequest, member: Member) { + if (request.can < 1) { + throw SodaException(messageKey = "content.donation.error.minimum_can") + } + + val creator = memberRepository.findCreatorByIdOrNull(request.creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") + + canPaymentService.spendCan( + memberId = member.id!!, + needCan = request.can, + canUsage = CanUsage.CHANNEL_DONATION, + creator = creator, + isSecret = request.isSecret, + container = request.container + ) + + val channelDonationMessage = ChannelDonationMessage( + can = request.can, + isSecret = request.isSecret, + additionalMessage = request.message.takeIf { it.isNotBlank() } + ) + channelDonationMessage.member = member + channelDonationMessage.creator = creator + channelDonationMessageRepository.save(channelDonationMessage) + } + + fun getChannelDonationList( + creatorId: Long, + member: Member, + offset: Long, + limit: Long + ): GetChannelDonationListResponse { + memberRepository.findCreatorByIdOrNull(creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") + + val startDateTime = LocalDateTime.now().minusMonths(1) + val isCreator = member.role == MemberRole.CREATOR && creatorId == member.id + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creatorId, + memberId = member.id!!, + isCreator = isCreator, + startDateTime = startDateTime + ) + + val items = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creatorId, + memberId = member.id!!, + isCreator = isCreator, + offset = offset, + limit = limit, + startDateTime = startDateTime + ).map { + GetChannelDonationListItem( + id = it.id!!, + memberId = it.member!!.id!!, + nickname = it.member!!.nickname, + profileUrl = if (it.member!!.profileImage != null) { + "$cloudFrontHost/${it.member!!.profileImage}" + } else { + "$cloudFrontHost/profile/default-profile.png" + }, + can = it.can, + isSecret = it.isSecret, + message = buildMessage(it.can, it.isSecret, it.additionalMessage), + createdAt = it.createdAt!!.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + } + + return GetChannelDonationListResponse(totalCount = totalCount, items = items) + } + + fun getChannelDonationListForProfile( + creatorId: Long, + member: Member, + limit: Long = 5 + ): List { + return getChannelDonationList( + creatorId = creatorId, + member = member, + offset = 0, + limit = limit + ).items + } + + private fun buildMessage(can: Int, isSecret: Boolean, additionalMessage: String?): String { + val key = if (isSecret) { + "explorer.channel_donation.message.default.secret" + } else { + "explorer.channel_donation.message.default.public" + } + val defaultMessage = getMessage(key, can) + + return if (additionalMessage.isNullOrBlank()) { + defaultMessage + } else { + "$defaultMessage\n\"$additionalMessage\"" + } + } + + private fun getMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang).orEmpty() + return if (args.isEmpty()) template else String.format(template, *args) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt new file mode 100644 index 00000000..070b0bc0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/GetChannelDonationListResponse.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +data class GetChannelDonationListResponse( + val totalCount: Int, + val items: List +) + +data class GetChannelDonationListItem( + val id: Long, + val memberId: Long, + val nickname: String, + val profileUrl: String, + val can: Int, + val isSecret: Boolean, + val message: String, + val createdAt: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt new file mode 100644 index 00000000..a15ccf18 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/PostChannelDonationRequest.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +data class PostChannelDonationRequest( + val creatorId: Long, + val can: Int, + val isSecret: Boolean = false, + val message: String = "", + val container: String +) 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 8e78ce2d..1fa07b51 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1770,6 +1770,16 @@ class SodaMessageSource { Lang.KO to "새 글이 등록되었습니다.", Lang.EN to "A new post has been added.", Lang.JA to "新しい投稿が登録されました。" + ), + "explorer.channel_donation.message.default.public" to mapOf( + Lang.KO to "%s캔을 후원하셨습니다.", + Lang.EN to "You sponsored %s cans.", + Lang.JA to "%sCANを支援しました。" + ), + "explorer.channel_donation.message.default.secret" to mapOf( + Lang.KO to "%s캔을 비밀후원하셨습니다.", + Lang.EN to "You secretly sponsored %s cans.", + Lang.JA to "%sCANをシークレット支援しました。" ) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt new file mode 100644 index 00000000..79ecd128 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationControllerTest.kt @@ -0,0 +1,106 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.common.SodaExceptionHandler +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.data.domain.PageRequest +import org.springframework.data.web.PageableHandlerMethodArgumentResolver +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +class ChannelDonationControllerTest { + private lateinit var channelDonationService: ChannelDonationService + private lateinit var controller: ChannelDonationController + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setup() { + channelDonationService = Mockito.mock(ChannelDonationService::class.java) + controller = ChannelDonationController(channelDonationService) + + mockMvc = MockMvcBuilders + .standaloneSetup(controller) + .setControllerAdvice(SodaExceptionHandler(LangContext(), SodaMessageSource())) + .setCustomArgumentResolvers( + AuthenticationPrincipalArgumentResolver(), + PageableHandlerMethodArgumentResolver() + ) + .build() + } + + @Test + fun shouldReturnErrorResponseWhenRequesterIsAnonymous() { + mockMvc.perform( + get("/explorer/profile/channel-donation") + .param("creatorId", "1") + .param("page", "0") + .param("size", "5") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요.")) + } + + @Test + fun shouldForwardPageableAndMemberToServiceWhenControllerMethodIsCalled() { + val member = createMember(id = 7L, role = MemberRole.USER, nickname = "viewer") + val item = GetChannelDonationListItem( + id = 1001L, + memberId = member.id!!, + nickname = member.nickname, + profileUrl = "https://cdn.test/profile/default-profile.png", + can = 3, + isSecret = false, + message = "3캔을 후원하셨습니다.", + createdAt = "2026-02-23T09:30:00" + ) + val response = GetChannelDonationListResponse(totalCount = 1, items = listOf(item)) + + Mockito.`when`( + channelDonationService.getChannelDonationList( + creatorId = 1L, + member = member, + offset = 10L, + limit = 5L + ) + ).thenReturn(response) + + val apiResponse = controller.getChannelDonationList( + creatorId = 1L, + member = member, + pageable = PageRequest.of(2, 5) + ) + + assertEquals(true, apiResponse.success) + assertEquals(1, apiResponse.data!!.totalCount) + assertEquals(1001L, apiResponse.data!!.items[0].id) + + Mockito.verify(channelDonationService).getChannelDonationList( + creatorId = 1L, + member = member, + offset = 10L, + limit = 5L + ) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt new file mode 100644 index 00000000..d1738bc0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessageRepositoryTest.kt @@ -0,0 +1,136 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest +@Import(QueryDslConfig::class) +class ChannelDonationMessageRepositoryTest @Autowired constructor( + private val channelDonationMessageRepository: ChannelDonationMessageRepository, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager +) { + @Test + fun shouldFilterByDateAndSortByCreatedAtAndIdDescForViewer() { + val creator = saveMember(nickname = "creator", role = MemberRole.CREATOR) + val viewer = saveMember(nickname = "viewer", role = MemberRole.USER) + val otherUser = saveMember(nickname = "other", role = MemberRole.USER) + + val now = LocalDateTime.now() + val tieTime = now.minusDays(2) + + val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) + val publicTieFirst = saveMessage(member = otherUser, creator = creator, can = 2, isSecret = false) + val publicTieSecond = saveMessage(member = viewer, creator = creator, can = 3, isSecret = false) + val secretMine = saveMessage(member = viewer, creator = creator, can = 4, isSecret = true) + val secretOther = saveMessage(member = otherUser, creator = creator, can = 5, isSecret = true) + + updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) + updateCreatedAt(publicTieFirst.id!!, tieTime) + updateCreatedAt(publicTieSecond.id!!, tieTime) + updateCreatedAt(secretMine.id!!, now.minusDays(1)) + updateCreatedAt(secretOther.id!!, now.minusHours(12)) + entityManager.flush() + entityManager.clear() + + val list = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creator.id!!, + memberId = viewer.id!!, + isCreator = false, + offset = 0, + limit = 10, + startDateTime = now.minusMonths(1) + ) + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creator.id!!, + memberId = viewer.id!!, + isCreator = false, + startDateTime = now.minusMonths(1) + ) + + assertEquals(3, list.size) + assertEquals(secretMine.id, list[0].id) + assertEquals(publicTieSecond.id, list[1].id) + assertEquals(publicTieFirst.id, list[2].id) + assertEquals(3, totalCount) + } + + @Test + fun shouldIncludeAllRecentSecretMessagesForCreator() { + val creator = saveMember(nickname = "creator2", role = MemberRole.CREATOR) + val viewer = saveMember(nickname = "viewer2", role = MemberRole.USER) + val otherUser = saveMember(nickname = "other2", role = MemberRole.USER) + + val now = LocalDateTime.now() + val oldPublic = saveMessage(member = viewer, creator = creator, can = 1, isSecret = false) + val recentPublic = saveMessage(member = viewer, creator = creator, can = 2, isSecret = false) + val recentSecretMine = saveMessage(member = viewer, creator = creator, can = 3, isSecret = true) + val recentSecretOther = saveMessage(member = otherUser, creator = creator, can = 4, isSecret = true) + + updateCreatedAt(oldPublic.id!!, now.minusMonths(2)) + updateCreatedAt(recentPublic.id!!, now.minusDays(3)) + updateCreatedAt(recentSecretMine.id!!, now.minusDays(2)) + updateCreatedAt(recentSecretOther.id!!, now.minusDays(1)) + entityManager.flush() + entityManager.clear() + + val list = channelDonationMessageRepository.getChannelDonationMessageList( + creatorId = creator.id!!, + memberId = creator.id!!, + isCreator = true, + offset = 0, + limit = 10, + startDateTime = now.minusMonths(1) + ) + + val totalCount = channelDonationMessageRepository.getChannelDonationMessageTotalCount( + creatorId = creator.id!!, + memberId = creator.id!!, + isCreator = true, + startDateTime = now.minusMonths(1) + ) + + assertEquals(3, list.size) + assertEquals(recentSecretOther.id, list[0].id) + assertEquals(recentSecretMine.id, list[1].id) + assertEquals(recentPublic.id, list[2].id) + assertEquals(3, totalCount) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + ) + } + + private fun saveMessage(member: Member, creator: Member, can: Int, isSecret: Boolean): ChannelDonationMessage { + val message = ChannelDonationMessage(can = can, isSecret = isSecret) + message.member = member + message.creator = creator + return channelDonationMessageRepository.saveAndFlush(message) + } + + private fun updateCreatedAt(id: Long, createdAt: LocalDateTime) { + entityManager.createQuery( + "update ChannelDonationMessage m set m.createdAt = :createdAt where m.id = :id" + ) + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt new file mode 100644 index 00000000..e3d86771 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationServiceTest.kt @@ -0,0 +1,175 @@ +package kr.co.vividnext.sodalive.explorer.profile.channelDonation + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class ChannelDonationServiceTest { + private lateinit var canPaymentService: CanPaymentService + private lateinit var memberRepository: MemberRepository + private lateinit var channelDonationMessageRepository: ChannelDonationMessageRepository + private lateinit var service: ChannelDonationService + + @BeforeEach + fun setup() { + canPaymentService = Mockito.mock(CanPaymentService::class.java) + memberRepository = Mockito.mock(MemberRepository::class.java) + channelDonationMessageRepository = Mockito.mock(ChannelDonationMessageRepository::class.java) + service = ChannelDonationService( + canPaymentService = canPaymentService, + memberRepository = memberRepository, + channelDonationMessageRepository = channelDonationMessageRepository, + messageSource = SodaMessageSource(), + langContext = LangContext(), + cloudFrontHost = "https://cdn.test" + ) + } + + @Test + fun shouldThrowWhenDonateCanIsLessThanOne() { + val member = createMember(id = 10L, role = MemberRole.USER, nickname = "viewer") + val request = PostChannelDonationRequest( + creatorId = 1L, + can = 0, + isSecret = false, + message = "", + container = "aos" + ) + + val exception = assertThrows(SodaException::class.java) { + service.donate(request, member) + } + + assertEquals("content.donation.error.minimum_can", exception.messageKey) + } + + @Test + fun shouldPassUserVisibilityFlagToRepositoryWhenRequesterIsNotCreator() { + val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") + val viewer = createMember(id = 2L, role = MemberRole.USER, nickname = "viewer") + val message = ChannelDonationMessage(can = 3, isSecret = true, additionalMessage = "응원합니다") + message.id = 1001L + message.member = viewer + message.creator = creator + message.createdAt = LocalDateTime.of(2026, 2, 20, 12, 0, 0) + + Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + anyLocalDateTime() + ) + ).thenReturn(1) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + ).thenReturn(listOf(message)) + + val result = service.getChannelDonationList( + creatorId = creator.id!!, + member = viewer, + offset = 0, + limit = 5 + ) + + assertEquals(1, result.totalCount) + assertEquals(1, result.items.size) + assertEquals("3캔을 비밀후원하셨습니다.\n\"응원합니다\"", result.items[0].message) + + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + anyLocalDateTime() + ) + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(viewer.id!!), + Mockito.eq(false), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + } + + @Test + fun shouldPassCreatorVisibilityFlagToRepositoryWhenRequesterIsCreatorSelf() { + val creator = createMember(id = 1L, role = MemberRole.CREATOR, nickname = "creator") + + Mockito.`when`(memberRepository.findCreatorByIdOrNull(creator.id!!)).thenReturn(creator) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + anyLocalDateTime() + ) + ).thenReturn(0) + Mockito.`when`( + channelDonationMessageRepository.getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + ).thenReturn(emptyList()) + + service.getChannelDonationList( + creatorId = creator.id!!, + member = creator, + offset = 0, + limit = 5 + ) + + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageTotalCount( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + anyLocalDateTime() + ) + Mockito.verify(channelDonationMessageRepository).getChannelDonationMessageList( + Mockito.eq(creator.id!!), + Mockito.eq(creator.id!!), + Mockito.eq(true), + Mockito.eq(0L), + Mockito.eq(5L), + anyLocalDateTime() + ) + } + + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + member.id = id + return member + } + + private fun anyLocalDateTime(): LocalDateTime { + Mockito.any(LocalDateTime::class.java) + return LocalDateTime.MIN + } +}