diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt index 5c9883fc..59496eb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/gift/dto/GiftResponse.kt @@ -59,6 +59,9 @@ data class GiftListResponse( priceCan = it.priceCan, categoryName = it.categoryName, sizeName = it.sizeName, + counterpartMemberId = it.counterpartMemberId, + counterpartNickname = it.counterpartNickname, + counterpartProfileImageUrl = it.counterpartProfileImageUrl, createdAt = it.createdAt?.toUtcIso() ) }, @@ -77,6 +80,9 @@ data class GiftListItemResponse( val priceCan: Int, val categoryName: String, val sizeName: String, + val counterpartMemberId: Long, + val counterpartNickname: String, + val counterpartProfileImageUrl: String, val createdAt: String? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt index f1c06608..f84b6890 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.gift.application import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRep import kr.co.vividnext.sodalive.v2.gift.domain.GiftDirection import kr.co.vividnext.sodalive.v2.gift.domain.GiftListType import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus +import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.data.repository.findByIdOrNull @@ -39,6 +41,9 @@ data class GiftListItemResult( val priceCan: Int, val categoryName: String, val sizeName: String, + val counterpartMemberId: Long, + val counterpartNickname: String, + val counterpartProfileImageUrl: String, val createdAt: LocalDateTime? ) @@ -99,7 +104,9 @@ class GiftQueryService( private val sizePriceRepository: GiftSizePriceRepository, private val giftRepository: GiftRepository, private val deliveryRepository: GiftDeliveryRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String ) { @Transactional(readOnly = true) fun getFormOptions() = GiftFormOptionsResult( @@ -140,9 +147,13 @@ class GiftQueryService( ) } + val membersById = memberRepository.findAllById( + gifts.content.flatMap { listOf(it.senderMemberId, it.recipientMemberId) }.toSet() + ).associateBy { it.id ?: throw invalidRequest() } + return GiftListResult( totalCount = gifts.totalElements, - items = gifts.content.map { it.toGiftListItem(memberId) }, + items = gifts.content.map { it.toGiftListItem(memberId, membersById) }, page = normalizedPage, size = normalizedSize, hasNext = gifts.hasNext() @@ -189,16 +200,31 @@ class GiftQueryService( ) } - private fun Gift.toGiftListItem(memberId: Long) = GiftListItemResult( - applicationNo = applicationNo, - direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED, - status = status, - statusName = status.displayName, - priceCan = salePriceCan, - categoryName = categoryNameSnapshot, - sizeName = sizeCode.displayName, - createdAt = createdAt - ) + private fun Gift.toGiftListItem(memberId: Long, membersById: Map): GiftListItemResult { + val direction = if (senderMemberId == memberId) GiftDirection.SENT else GiftDirection.RECEIVED + val counterpartId = if (direction == GiftDirection.SENT) recipientMemberId else senderMemberId + val counterpart = membersById[counterpartId] ?: throw invalidRequest() + + return GiftListItemResult( + applicationNo = applicationNo, + direction = direction, + status = status, + statusName = status.displayName, + priceCan = salePriceCan, + categoryName = categoryNameSnapshot, + sizeName = sizeCode.displayName, + counterpartMemberId = counterpartId, + counterpartNickname = counterpart.nickname, + counterpartProfileImageUrl = counterpart.profileImageUrl(), + createdAt = createdAt + ) + } + + private fun Member.profileImageUrl() = if (profileImage.isNullOrBlank()) { + "$cloudFrontHost/profile/default-profile.png" + } else { + "$cloudFrontHost/$profileImage" + } private fun Gift.directionFor(memberId: Long) = when { senderMemberId == memberId -> GiftDirection.SENT diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt index 8a195ba9..9bc22f37 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt @@ -46,7 +46,9 @@ import java.time.LocalDateTime @SpringBootTest( properties = [ - "spring.datasource.url=jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + "spring.datasource.url=" + + "jdbc:h2:mem:gift-controller;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", + "cloud.aws.cloud-front.host=https://cdn.test" ] ) @AutoConfigureMockMvc @@ -86,13 +88,26 @@ class GiftControllerTest @Autowired constructor( @Test @DisplayName("회원은 선물함 목록에서 보낸 선물과 노출 가능한 받은 선물을 페이징 응답으로 조회한다") fun shouldListGifts() { - val member = Member(password = "password", nickname = "member").apply { id = 10L } - saveGift("G-CONTROLLER-SENT", senderMemberId = 10L, recipientMemberId = 20L, status = GiftStatus.RECEIVED) - saveGift("G-CONTROLLER-HIDDEN", senderMemberId = 20L, recipientMemberId = 10L, status = GiftStatus.RECEIVED) + val member = memberRepository.save(Member(password = "password", nickname = "member")) + val counterpart = memberRepository.save( + Member(password = "password", nickname = "counterpart", profileImage = "profile/counterpart.png") + ) + saveGift( + "G-CONTROLLER-SENT", + senderMemberId = member.id!!, + recipientMemberId = counterpart.id!!, + status = GiftStatus.RECEIVED + ) + saveGift( + "G-CONTROLLER-HIDDEN", + senderMemberId = counterpart.id!!, + recipientMemberId = member.id!!, + status = GiftStatus.RECEIVED + ) saveGift( "G-CONTROLLER-VISIBLE", - senderMemberId = 20L, - recipientMemberId = 10L, + senderMemberId = counterpart.id!!, + recipientMemberId = member.id!!, status = GiftStatus.TRACKING_REGISTERED ) @@ -117,6 +132,9 @@ class GiftControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].priceCan").value(80)) .andExpect(jsonPath("$.data.items[0].categoryName").value("인형")) .andExpect(jsonPath("$.data.items[0].sizeName").value("소형")) + .andExpect(jsonPath("$.data.items[0].counterpartMemberId").value(counterpart.id!!)) + .andExpect(jsonPath("$.data.items[0].counterpartNickname").value("counterpart")) + .andExpect(jsonPath("$.data.items[0].counterpartProfileImageUrl").value("https://cdn.test/profile/counterpart.png")) .andExpect(jsonPath("$.data.items[0].createdAt").value(endsWith("Z"))) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt index 0da24ecb..2e4cb51b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt @@ -61,10 +61,17 @@ class GiftQueryServiceTest @Autowired constructor( @Test @DisplayName("보낸 선물은 접수 직후 선물함에 노출된다") fun shouldListReceivedGiftForSender() { - saveGift("G-SENT-RECEIVED", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + val sender = saveMember("fan") + val recipient = saveMember("creator", "profile/creator.png") + saveGift( + "G-SENT-RECEIVED", + senderMemberId = sender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = sender.id!!, type = GiftListType.SENT, page = 0, size = 20 @@ -79,22 +86,33 @@ class GiftQueryServiceTest @Autowired constructor( assertEquals(80, result.items.single().priceCan) assertEquals("인형", result.items.single().categoryName) assertEquals("소형", result.items.single().sizeName) + assertEquals(recipient.id, result.items.single().counterpartMemberId) + assertEquals("creator", result.items.single().counterpartNickname) + assertEquals("https://cdn.test/profile/creator.png", result.items.single().counterpartProfileImageUrl) assertNotNull(result.items.single().createdAt) } @Test @DisplayName("받은 선물은 운송장 등록 이후 상태만 선물함에 노출된다") fun shouldExcludeReceivedOnlyGiftAndListVisibleGiftForRecipient() { - saveGift("G-RECEIVED-HIDDEN", senderMemberId = 2L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + val recipient = saveMember("creator") + val hiddenSender = saveMember("hidden-fan") + val visibleSender = saveMember("visible-fan") + saveGift( + "G-RECEIVED-HIDDEN", + senderMemberId = hiddenSender.id!!, + recipientMemberId = recipient.id!!, + status = GiftStatus.RECEIVED + ) saveGift( "G-RECEIVED-VISIBLE", - senderMemberId = 3L, - recipientMemberId = MEMBER_ID, + senderMemberId = visibleSender.id!!, + recipientMemberId = recipient.id!!, status = GiftStatus.TRACKING_REGISTERED ) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = recipient.id!!, type = GiftListType.RECEIVED, page = 0, size = 20 @@ -103,22 +121,29 @@ class GiftQueryServiceTest @Autowired constructor( assertEquals(1L, result.totalCount) assertEquals(listOf("G-RECEIVED-VISIBLE"), result.items.map { it.applicationNo }) assertEquals(GiftDirection.RECEIVED, result.items.single().direction) + assertEquals(visibleSender.id, result.items.single().counterpartMemberId) + assertEquals("visible-fan", result.items.single().counterpartNickname) + assertEquals("https://cdn.test/profile/default-profile.png", result.items.single().counterpartProfileImageUrl) } @Test @DisplayName("전체 선물함은 보낸 선물과 노출 가능한 받은 선물을 방향과 함께 반환한다") fun shouldListSentAndVisibleReceivedGiftsWithDirectionsForAll() { - saveGift("G-ALL-SENT", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.CANCELED) + val member = saveMember("member") + val creator = saveMember("creator") + val fan = saveMember("fan") + val hiddenFan = saveMember("hidden-fan") + saveGift("G-ALL-SENT", senderMemberId = member.id!!, recipientMemberId = creator.id!!, status = GiftStatus.CANCELED) saveGift( "G-ALL-RECEIVED", - senderMemberId = 3L, - recipientMemberId = MEMBER_ID, + senderMemberId = fan.id!!, + recipientMemberId = member.id!!, status = GiftStatus.ARRIVED_AT_MAILBOX ) - saveGift("G-ALL-HIDDEN", senderMemberId = 4L, recipientMemberId = MEMBER_ID, status = GiftStatus.RECEIVED) + saveGift("G-ALL-HIDDEN", senderMemberId = hiddenFan.id!!, recipientMemberId = member.id!!, status = GiftStatus.RECEIVED) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = member.id!!, type = GiftListType.ALL, page = 0, size = 20 @@ -129,15 +154,21 @@ class GiftQueryServiceTest @Autowired constructor( mapOf("G-ALL-SENT" to GiftDirection.SENT, "G-ALL-RECEIVED" to GiftDirection.RECEIVED), result.items.associate { it.applicationNo to it.direction } ) + assertEquals( + mapOf("G-ALL-SENT" to creator.id, "G-ALL-RECEIVED" to fan.id), + result.items.associate { it.applicationNo to it.counterpartMemberId } + ) } @Test @DisplayName("선물함 page와 size는 음수 또는 0이면 기본값으로 보정된다") fun shouldNormalizeGiftListPageAndSize() { - saveGift("G-PAGING", senderMemberId = MEMBER_ID, recipientMemberId = 2L, status = GiftStatus.RECEIVED) + val sender = saveMember("fan") + val recipient = saveMember("creator") + saveGift("G-PAGING", senderMemberId = sender.id!!, recipientMemberId = recipient.id!!, status = GiftStatus.RECEIVED) val result = queryService().getGifts( - memberId = MEMBER_ID, + memberId = sender.id!!, type = GiftListType.SENT, page = -1, size = 0 @@ -379,7 +410,9 @@ class GiftQueryServiceTest @Autowired constructor( ) ) - private fun saveMember(nickname: String) = memberRepository.save(Member(password = "password", nickname = nickname)) + private fun saveMember(nickname: String, profileImage: String? = null) = memberRepository.save( + Member(password = "password", nickname = nickname, profileImage = profileImage) + ) private fun giftCategory( classificationNumber: String, @@ -416,11 +449,11 @@ class GiftQueryServiceTest @Autowired constructor( sizePriceRepository, giftRepository, deliveryRepository, - memberRepository + memberRepository, + "https://cdn.test" ) private companion object { - const val MEMBER_ID = 1L val NOW: LocalDateTime = LocalDateTime.of(2026, 9, 29, 12, 0) } }