feat(gift): 관리자 선물함 HTTP 조회를 추가한다

This commit is contained in:
2026-09-30 18:41:57 +09:00
parent b5a5d1318b
commit 15ce9aab41
3 changed files with 288 additions and 2 deletions
@@ -3,12 +3,16 @@ package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftCategoryResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftDetailResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftListResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftMarkUndeliverableRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftOperationStatusResponse
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceRequest
import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse import kr.co.vividnext.sodalive.v2.api.admin.gift.dto.AdminGiftSizePriceResponse
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminQueryService
import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService import kr.co.vividnext.sodalive.v2.gift.application.GiftAdminService
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
@@ -17,12 +21,30 @@ import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@RequestMapping("/api/v2/admin") @RequestMapping("/api/v2/admin")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class AdminGiftController(private val service: GiftAdminService) { class AdminGiftController(
private val service: GiftAdminService,
private val queryService: GiftAdminQueryService
) {
@GetMapping("/gifts")
fun getGifts(
@RequestParam(required = false) status: GiftStatus?,
@RequestParam(required = false) applicationNo: String?,
@RequestParam(required = false) nickname: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(AdminGiftListResponse.from(queryService.getGifts(status, applicationNo, nickname, page, size)))
@GetMapping("/gifts/{applicationNo}")
fun getGiftDetail(@PathVariable applicationNo: String) = ApiResponse.ok(
AdminGiftDetailResponse.from(queryService.getGiftDetail(applicationNo))
)
@GetMapping("/gift-categories") @GetMapping("/gift-categories")
fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) }) fun getCategories() = ApiResponse.ok(service.getCategories().map { AdminGiftCategoryResponse.from(it) })
@@ -1,6 +1,12 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.dto package kr.co.vividnext.sodalive.v2.api.admin.gift.dto
import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftDetailResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftInboundDeliveryInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListItemResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftListResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftMemberInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.AdminGiftProductInfoResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult import kr.co.vividnext.sodalive.v2.gift.application.GiftCategoryResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult import kr.co.vividnext.sodalive.v2.gift.application.GiftOperationStatusResult
import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult import kr.co.vividnext.sodalive.v2.gift.application.GiftSizePriceResult
@@ -63,3 +69,121 @@ data class AdminGiftOperationStatusResponse(
) )
} }
} }
data class AdminGiftListResponse(
val totalCount: Long,
val items: List<AdminGiftListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
) {
companion object {
fun from(result: AdminGiftListResult) = AdminGiftListResponse(
totalCount = result.totalCount,
items = result.items.map { AdminGiftListItemResponse.from(it) },
page = result.page,
size = result.size,
hasNext = result.hasNext
)
}
}
data class AdminGiftListItemResponse(
val applicationNo: String,
val senderNickname: String,
val recipientNickname: String,
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String,
val courierCompanyName: String?,
val trackingNumber: String?,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
) {
companion object {
fun from(result: AdminGiftListItemResult) = AdminGiftListItemResponse(
applicationNo = result.applicationNo,
senderNickname = result.senderNickname,
recipientNickname = result.recipientNickname,
sizeCode = result.sizeCode,
sizeName = result.sizeName,
categoryName = result.categoryName,
classificationNumber = result.classificationNumber,
courierCompanyName = result.courierCompanyName,
trackingNumber = result.trackingNumber,
status = result.status,
statusName = result.statusName,
availableActions = result.availableActions
)
}
}
data class AdminGiftDetailResponse(
val applicationNo: String,
val senderInfo: AdminGiftMemberInfoResponse,
val recipientInfo: AdminGiftMemberInfoResponse,
val productInfo: AdminGiftProductInfoResponse,
val inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse,
val status: GiftStatus,
val statusName: String,
val availableActions: List<String>
) {
companion object {
fun from(result: AdminGiftDetailResult) = AdminGiftDetailResponse(
applicationNo = result.applicationNo,
senderInfo = AdminGiftMemberInfoResponse.from(result.senderInfo),
recipientInfo = AdminGiftMemberInfoResponse.from(result.recipientInfo),
productInfo = AdminGiftProductInfoResponse.from(result.productInfo),
inboundDeliveryInfo = AdminGiftInboundDeliveryInfoResponse.from(result.inboundDeliveryInfo),
status = result.status,
statusName = result.statusName,
availableActions = result.availableActions
)
}
}
data class AdminGiftMemberInfoResponse(
val nickname: String,
val name: String,
val phoneNumber: String,
val address: String
) {
companion object {
fun from(result: AdminGiftMemberInfoResult) = AdminGiftMemberInfoResponse(
nickname = result.nickname,
name = result.name,
phoneNumber = result.phoneNumber,
address = result.address
)
}
}
data class AdminGiftProductInfoResponse(
val sizeCode: String,
val sizeName: String,
val categoryName: String,
val classificationNumber: String
) {
companion object {
fun from(result: AdminGiftProductInfoResult) = AdminGiftProductInfoResponse(
sizeCode = result.sizeCode,
sizeName = result.sizeName,
categoryName = result.categoryName,
classificationNumber = result.classificationNumber
)
}
}
data class AdminGiftInboundDeliveryInfoResponse(
val courierCompanyName: String?,
val trackingNumber: String?
) {
companion object {
fun from(result: AdminGiftInboundDeliveryInfoResult) = AdminGiftInboundDeliveryInfoResponse(
courierCompanyName = result.courierCompanyName,
trackingNumber = result.trackingNumber
)
}
}
@@ -1,7 +1,10 @@
package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web package kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.`in`.web
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.Gift
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategory
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftCategoryRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDelivery
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftDeliveryRepository
@@ -46,12 +49,93 @@ class AdminGiftControllerTest @Autowired constructor(
private val categoryRepository: GiftCategoryRepository, private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository, private val sizePriceRepository: GiftSizePriceRepository,
private val giftRepository: GiftRepository, private val giftRepository: GiftRepository,
private val giftDeliveryRepository: GiftDeliveryRepository private val giftDeliveryRepository: GiftDeliveryRepository,
private val memberRepository: MemberRepository
) { ) {
@BeforeEach @BeforeEach
fun deleteGiftFixtures() { fun deleteGiftFixtures() {
giftDeliveryRepository.deleteAll() giftDeliveryRepository.deleteAll()
giftRepository.deleteAll() giftRepository.deleteAll()
categoryRepository.deleteAll()
}
@Test
@DisplayName("관리자는 전체 선물함 목록을 필터링해 조회한다")
fun shouldListAdminGifts() {
val (gift, _) = saveAdminGift(
applicationNo = "A-1002609300001",
status = GiftStatus.TRACKING_REGISTERED,
senderNickname = "admin-fan-alpha",
recipientNickname = "admin-creator-alpha",
withTracking = true
)
mockMvc.perform(
get("/api/v2/admin/gifts")
.with(user("admin").roles("ADMIN"))
.param("status", "TRACKING_REGISTERED")
.param("applicationNo", "0001")
.param("nickname", "alpha")
.param("page", "0")
.param("size", "20")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].applicationNo").value(gift.applicationNo))
.andExpect(jsonPath("$.data.items[0].senderNickname").value("admin-fan-alpha"))
.andExpect(jsonPath("$.data.items[0].recipientNickname").value("admin-creator-alpha"))
.andExpect(jsonPath("$.data.items[0].sizeCode").value("SMALL"))
.andExpect(jsonPath("$.data.items[0].sizeName").value("소형"))
.andExpect(jsonPath("$.data.items[0].categoryName").value("인형"))
.andExpect(jsonPath("$.data.items[0].classificationNumber").value("100"))
.andExpect(jsonPath("$.data.items[0].courierCompanyName").value("CJ대한통운"))
.andExpect(jsonPath("$.data.items[0].trackingNumber").value("1234567890"))
.andExpect(jsonPath("$.data.items[0].status").value("TRACKING_REGISTERED"))
.andExpect(jsonPath("$.data.items[0].statusName").value("발송 확인"))
.andExpect(jsonPath("$.data.items[0].availableActions[0]").value("ARRIVE_MAILBOX"))
.andExpect(jsonPath("$.data.items[0].availableActions[1]").value("MARK_UNDELIVERABLE"))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("관리자는 선물 상세를 조회하고 미입력 수취인 정보는 빈 문자열로 받는다")
fun shouldGetAdminGiftDetail() {
saveAdminGift(
applicationNo = "A-1002609300002",
status = GiftStatus.ARRIVED_AT_MAILBOX,
senderNickname = "admin-fan-beta",
recipientNickname = "admin-creator-beta",
withTracking = true
)
mockMvc.perform(
get("/api/v2/admin/gifts/A-1002609300002")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.applicationNo").value("A-1002609300002"))
.andExpect(jsonPath("$.data.senderInfo.nickname").value("admin-fan-beta"))
.andExpect(jsonPath("$.data.senderInfo.name").value("홍길동"))
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value("01012345678"))
.andExpect(jsonPath("$.data.senderInfo.address").value("(06234) 서울시 강남구, 101동 1001호"))
.andExpect(jsonPath("$.data.recipientInfo.nickname").value("admin-creator-beta"))
.andExpect(jsonPath("$.data.recipientInfo.name").value(""))
.andExpect(jsonPath("$.data.recipientInfo.phoneNumber").value(""))
.andExpect(jsonPath("$.data.recipientInfo.address").value(""))
.andExpect(jsonPath("$.data.productInfo.sizeCode").value("SMALL"))
.andExpect(jsonPath("$.data.productInfo.sizeName").value("소형"))
.andExpect(jsonPath("$.data.productInfo.categoryName").value("인형"))
.andExpect(jsonPath("$.data.productInfo.classificationNumber").value("100"))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.courierCompanyName").value("CJ대한통운"))
.andExpect(jsonPath("$.data.inboundDeliveryInfo.trackingNumber").value("1234567890"))
.andExpect(jsonPath("$.data.status").value("ARRIVED_AT_MAILBOX"))
.andExpect(jsonPath("$.data.statusName").value("사서함 도착"))
.andExpect(jsonPath("$.data.availableActions[0]").value("COMPLETE_INSPECTION"))
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
} }
@Test @Test
@@ -242,6 +326,62 @@ class AdminGiftControllerTest @Autowired constructor(
) )
} }
private fun saveAdminGift(
applicationNo: String,
status: GiftStatus,
senderNickname: String,
recipientNickname: String,
withTracking: Boolean
): Pair<Gift, GiftDelivery> {
val sender = memberRepository.save(Member(password = "password", nickname = senderNickname))
val recipient = memberRepository.save(Member(password = "password", nickname = recipientNickname))
val category = categoryRepository.save(
GiftCategory(
classificationNumber = "100",
categoryCode = "ADMIN_${applicationNo.takeLast(4)}",
name = "인형",
receiptCode = "A",
representativeItem = "피규어"
)
)
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = sender.id!!,
recipientMemberId = recipient.id!!,
status = status,
sizeCode = GiftSize.SMALL,
categoryId = category.id!!,
categoryNameSnapshot = category.name,
salePriceCan = 80,
canUsageId = 10L,
senderTermsAgreed = true,
senderPrivacyAgreed = true,
senderTermsAgreedAt = NOW.minusDays(1),
senderPrivacyAgreedAt = NOW.minusDays(1),
damageWaiverAgreed = true
)
)
val delivery = giftDeliveryRepository.save(
GiftDelivery(
giftId = requireNotNull(gift.id),
senderName = "홍길동",
senderPhoneNumber = "01012345678",
senderZipCode = "06234",
senderAddress = "서울시 강남구",
senderAddressDetail = "101동 1001호",
trackingDeadlineAt = NOW.minusDays(1)
).apply {
if (withTracking) {
courierCompanyName = "CJ대한통운"
trackingNumber = "1234567890"
trackingRegisteredAt = NOW.minusHours(1)
}
}
)
return gift to delivery
}
private companion object { private companion object {
const val APPLICATION_NO = "G20260929000001" const val APPLICATION_NO = "G20260929000001"
const val SENDER_MEMBER_ID = 1L const val SENDER_MEMBER_ID = 1L