feat(gift): 선물 신청 명령 서비스를 추가한다

This commit is contained in:
2026-09-30 13:37:06 +09:00
parent 71e7195414
commit 95b3b16897
2 changed files with 1236 additions and 0 deletions
@@ -0,0 +1,368 @@
package kr.co.vividnext.sodalive.v2.gift.application
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
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
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.GiftRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReview
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftReviewRepository
import kr.co.vividnext.sodalive.v2.gift.adapter.out.persistence.GiftSizePriceRepository
import kr.co.vividnext.sodalive.v2.gift.domain.GiftSize
import kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
data class GiftApplicationCommand(
val recipientMemberId: Long,
val senderName: String,
val senderPhoneNumber: String,
val senderZipCode: String,
val senderAddress: String,
val senderAddressDetail: String?,
val sizeCode: GiftSize,
val categoryId: Long,
val senderTermsAgreed: Boolean,
val senderPrivacyAgreed: Boolean,
val damageWaiverAgreed: Boolean
)
data class GiftApplicationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val trackingDeadlineAt: LocalDateTime
)
data class GiftCancellationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val priceCan: Int,
val canceledAt: LocalDateTime
)
data class GiftTrackingRegistrationCommand(
val courierCompanyName: String,
val trackingNumber: String
)
data class GiftTrackingRegistrationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val courierCompanyName: String,
val trackingNumber: String,
val trackingRegisteredAt: LocalDateTime,
val recipientAddressDeadlineAt: LocalDateTime
)
data class GiftRecipientAddressRegistrationCommand(
val name: String,
val phoneNumber: String,
val zipCode: String,
val address: String,
val addressDetail: String?,
val recipientTermsAgreed: Boolean,
val recipientPrivacyAgreed: Boolean
)
data class GiftRecipientAddressRegistrationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val recipientAddressRegisteredAt: LocalDateTime
)
data class GiftDeliveryConfirmationResult(
val applicationNo: String,
val status: GiftStatus,
val statusName: String,
val deliveredAt: LocalDateTime
)
data class GiftReviewCommand(
val rating: Int,
val keywords: List<String>,
val comment: String?
)
data class GiftReviewResult(
val reviewId: Long,
val applicationNo: String,
val rating: Int,
val keywords: List<String>,
val comment: String?,
val createdAt: LocalDateTime?
)
@Service
class GiftCommandService(
private val giftRepository: GiftRepository,
private val deliveryRepository: GiftDeliveryRepository,
private val reviewRepository: GiftReviewRepository,
private val categoryRepository: GiftCategoryRepository,
private val sizePriceRepository: GiftSizePriceRepository,
private val memberRepository: MemberRepository,
private val applicationNoGenerator: GiftApplicationNoGenerator,
private val canPaymentService: CanPaymentService,
private val giftPushService: GiftPushService,
private val now: () -> LocalDateTime = { LocalDateTime.now() }
) {
@Transactional
fun registerGift(member: Member, command: GiftApplicationCommand): GiftApplicationResult {
validate(command)
val senderMemberId = member.id ?: throw invalidRequest()
memberRepository.findCreatorByIdOrNull(command.recipientMemberId) ?: throw invalidRequest()
val category = categoryRepository.findByIdOrNull(command.categoryId)?.takeIf { it.isActive } ?: throw invalidRequest()
if (category.requiresDamageWaiver && !command.damageWaiverAgreed) throw invalidRequest()
val sizePrice = sizePriceRepository.findBySizeCode(command.sizeCode)?.takeIf { it.isActive } ?: throw invalidRequest()
val appliedAt = now()
val trackingDeadlineAt = appliedAt.plusDays(3)
val applicationNo = applicationNoGenerator.generate(appliedAt)
val canUsageId = canPaymentService.spendGiftCan(
senderMemberId,
sizePrice.salePriceCan,
member.container.ifBlank { "web" }
)
val gift = giftRepository.save(
Gift(
applicationNo = applicationNo,
senderMemberId = senderMemberId,
recipientMemberId = command.recipientMemberId,
status = GiftStatus.RECEIVED,
sizeCode = command.sizeCode,
categoryId = category.id ?: throw invalidRequest(),
categoryNameSnapshot = category.name,
salePriceCan = sizePrice.salePriceCan,
canUsageId = canUsageId,
senderTermsAgreed = command.senderTermsAgreed,
senderPrivacyAgreed = command.senderPrivacyAgreed,
senderTermsAgreedAt = appliedAt,
senderPrivacyAgreedAt = appliedAt,
damageWaiverAgreed = command.damageWaiverAgreed
)
)
deliveryRepository.save(
GiftDelivery(
giftId = gift.id ?: throw invalidRequest(),
senderName = command.senderName,
senderPhoneNumber = command.senderPhoneNumber,
senderZipCode = command.senderZipCode,
senderAddress = command.senderAddress,
senderAddressDetail = command.senderAddressDetail,
trackingDeadlineAt = trackingDeadlineAt
)
)
return GiftApplicationResult(
applicationNo = applicationNo,
status = GiftStatus.RECEIVED,
statusName = GiftStatus.RECEIVED.displayName,
priceCan = sizePrice.salePriceCan,
trackingDeadlineAt = trackingDeadlineAt
)
}
@Transactional
fun writeReview(member: Member, applicationNo: String, command: GiftReviewCommand): GiftReviewResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
val giftId = gift.id ?: throw invalidRequest()
if (
gift.senderMemberId != senderMemberId ||
gift.status != GiftStatus.DELIVERED ||
reviewRepository.findByGiftId(giftId) != null
) {
throw invalidRequest()
}
validate(command)
val review = reviewRepository.save(
GiftReview(
giftId = giftId,
senderMemberId = senderMemberId,
rating = command.rating,
keywords = command.keywords.joinToString(","),
comment = command.comment
)
)
return GiftReviewResult(
reviewId = review.id ?: throw invalidRequest(),
applicationNo = gift.applicationNo,
rating = review.rating,
keywords = review.keywords?.takeIf { it.isNotBlank() }?.split(",") ?: emptyList(),
comment = review.comment,
createdAt = review.createdAt
)
}
@Transactional
fun cancelGift(member: Member, applicationNo: String): GiftCancellationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
val canceledAt = now()
gift.status = GiftStatus.CANCELED
deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest())?.canceledAt = canceledAt
canPaymentService.refundGiftCan(senderMemberId, gift.canUsageId)
return GiftCancellationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
priceCan = gift.salePriceCan,
canceledAt = canceledAt
)
}
@Transactional
fun registerTracking(
member: Member,
applicationNo: String,
command: GiftTrackingRegistrationCommand
): GiftTrackingRegistrationResult {
val senderMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.senderMemberId != senderMemberId || gift.status != GiftStatus.RECEIVED) throw invalidRequest()
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val trackingRegisteredAt = now()
val recipientAddressDeadlineAt = trackingRegisteredAt.plusDays(7)
gift.status = GiftStatus.TRACKING_REGISTERED
delivery.courierCompanyName = command.courierCompanyName
delivery.trackingNumber = command.trackingNumber
delivery.trackingRegisteredAt = trackingRegisteredAt
delivery.recipientAddressDeadlineAt = recipientAddressDeadlineAt
giftPushService.sendTrackingRegisteredToRecipient(gift.applicationNo, gift.recipientMemberId)
return GiftTrackingRegistrationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
courierCompanyName = command.courierCompanyName,
trackingNumber = command.trackingNumber,
trackingRegisteredAt = trackingRegisteredAt,
recipientAddressDeadlineAt = recipientAddressDeadlineAt
)
}
@Transactional
fun registerRecipientAddress(
member: Member,
applicationNo: String,
command: GiftRecipientAddressRegistrationCommand
): GiftRecipientAddressRegistrationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status !in RECIPIENT_ADDRESS_REGISTRABLE_STATUSES) {
throw invalidRequest()
}
validate(command)
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val registeredAt = now()
delivery.recipientName = command.name
delivery.recipientPhoneNumber = command.phoneNumber
delivery.recipientZipCode = command.zipCode
delivery.recipientAddress = command.address
delivery.recipientAddressDetail = command.addressDetail
gift.recipientTermsAgreed = command.recipientTermsAgreed
gift.recipientPrivacyAgreed = command.recipientPrivacyAgreed
gift.recipientTermsAgreedAt = registeredAt
gift.recipientPrivacyAgreedAt = registeredAt
return GiftRecipientAddressRegistrationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
recipientAddressRegisteredAt = registeredAt
)
}
@Transactional
fun confirmDelivery(member: Member, applicationNo: String): GiftDeliveryConfirmationResult {
val recipientMemberId = member.id ?: throw invalidRequest()
val gift = giftRepository.findByApplicationNo(applicationNo) ?: throw invalidRequest()
if (gift.recipientMemberId != recipientMemberId || gift.status != GiftStatus.INSPECTION_COMPLETED) {
throw invalidRequest()
}
val delivery = deliveryRepository.findByGiftId(gift.id ?: throw invalidRequest()) ?: throw invalidRequest()
val deliveredAt = now()
gift.status = GiftStatus.DELIVERED
delivery.deliveredAt = deliveredAt
return GiftDeliveryConfirmationResult(
applicationNo = gift.applicationNo,
status = gift.status,
statusName = gift.status.displayName,
deliveredAt = deliveredAt
)
}
private fun validate(command: GiftApplicationCommand) {
if (
command.senderName.isBlank() ||
command.senderPhoneNumber.isBlank() ||
command.senderZipCode.isBlank() ||
command.senderAddress.isBlank() ||
!command.senderTermsAgreed ||
!command.senderPrivacyAgreed
) {
throw invalidRequest()
}
}
private fun validate(command: GiftTrackingRegistrationCommand) {
if (
command.courierCompanyName.isBlank() ||
command.courierCompanyName.length > 50 ||
command.trackingNumber.isBlank()
) {
throw invalidRequest()
}
}
private fun validate(command: GiftRecipientAddressRegistrationCommand) {
if (
command.name.isBlank() ||
command.phoneNumber.isBlank() ||
command.zipCode.isBlank() ||
command.address.isBlank() ||
!command.recipientTermsAgreed ||
!command.recipientPrivacyAgreed
) {
throw invalidRequest()
}
}
private fun validate(command: GiftReviewCommand) {
if (
command.rating !in 1..5 ||
command.keywords.any { it.length > 255 } ||
(command.comment?.length ?: 0) > 255
) {
throw invalidRequest()
}
}
private fun invalidRequest() = SodaException(messageKey = "common.error.invalid_request")
private companion object {
val RECIPIENT_ADDRESS_REGISTRABLE_STATUSES = setOf(
GiftStatus.TRACKING_REGISTERED,
GiftStatus.ARRIVED_AT_MAILBOX,
GiftStatus.INSPECTION_COMPLETED
)
}
}