diff --git a/build.gradle.kts b/build.gradle.kts index 6a91807..93b18cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "2.2.21" kotlin("plugin.spring") version "2.2.21" + kotlin("plugin.jpa") version "2.2.21" id("org.springframework.boot") version "4.0.2" id("io.spring.dependency-management") version "1.1.7" id("com.google.devtools.ksp") version "2.3.4" apply false diff --git a/src/main/kotlin/org/example/ticket/TicketApplication.kt b/src/main/kotlin/org/example/SecondChanceApplication.kt similarity index 63% rename from src/main/kotlin/org/example/ticket/TicketApplication.kt rename to src/main/kotlin/org/example/SecondChanceApplication.kt index 36c0af1..66be87e 100644 --- a/src/main/kotlin/org/example/ticket/TicketApplication.kt +++ b/src/main/kotlin/org/example/SecondChanceApplication.kt @@ -1,11 +1,11 @@ -package org.example.ticket +package org.example import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @SpringBootApplication -class TicketApplication +class SecondChanceApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/src/main/kotlin/org/example/deal/application/dto/DealEndDto.kt b/src/main/kotlin/org/example/deal/application/dto/DealEndDto.kt new file mode 100644 index 0000000..da02873 --- /dev/null +++ b/src/main/kotlin/org/example/deal/application/dto/DealEndDto.kt @@ -0,0 +1,7 @@ +package org.example.deal.application.dto + +import org.example.deal.domain.enum.PaymentType + +data class DealEndDto( + val paymentType: PaymentType +) diff --git a/src/main/kotlin/org/example/deal/application/dto/DealResponseDto.kt b/src/main/kotlin/org/example/deal/application/dto/DealResponseDto.kt new file mode 100644 index 0000000..6e7d294 --- /dev/null +++ b/src/main/kotlin/org/example/deal/application/dto/DealResponseDto.kt @@ -0,0 +1,14 @@ +package org.example.deal.application.dto + +import org.example.deal.domain.enum.DealStatus +import java.math.BigDecimal +import java.time.LocalDateTime + +data class DealResponseDto( + val barcode: String, + val sellerName: String, + val buyerName: String, + val sellingPrice: BigDecimal, + val reservedDateTime: LocalDateTime, + val dealStatus: DealStatus +) diff --git a/src/main/kotlin/org/example/deal/application/dto/DealStartDto.kt b/src/main/kotlin/org/example/deal/application/dto/DealStartDto.kt new file mode 100644 index 0000000..77dae54 --- /dev/null +++ b/src/main/kotlin/org/example/deal/application/dto/DealStartDto.kt @@ -0,0 +1,7 @@ +package org.example.deal.application.dto + +data class DealStartDto( + val barcode: String, + val buyerName: String, +) + diff --git a/src/main/kotlin/org/example/deal/application/service/DealService.kt b/src/main/kotlin/org/example/deal/application/service/DealService.kt new file mode 100644 index 0000000..d1eaa7b --- /dev/null +++ b/src/main/kotlin/org/example/deal/application/service/DealService.kt @@ -0,0 +1,73 @@ +package org.example.deal.application.service + +import jakarta.transaction.Transactional +import org.example.deal.application.dto.DealEndDto +import org.example.deal.application.dto.DealResponseDto +import org.example.deal.application.dto.DealStartDto +import org.example.deal.domain.model.Deal +import org.example.deal.infrastructure.api.PaymentGatewayResolver +import org.example.deal.infrastructure.repository.DealJpaRepository +import org.example.ticket.infrastructure.repository.TicketJpaRepository +import org.springframework.stereotype.Service + +@Service +class DealService( + private val dealRepository: DealJpaRepository, + private val ticketRepository: TicketJpaRepository, + private val paymentGatewayResolver: PaymentGatewayResolver +) { + @Transactional + fun dealStart(dealStartDto: DealStartDto): DealResponseDto { + val ticket = requireNotNull(ticketRepository.findByBarcode(dealStartDto.barcode)) { "존재하지 않는 티켓입니다." } + val sellingPrice = requireNotNull(ticket.sellingPrice) { "판매가가 설정되지 않았습니다." } + + val deal = Deal( + barcode = ticket.barcode, + sellerName = ticket.sellerName, + buyerName = dealStartDto.buyerName, + sellingPrice = sellingPrice + ) + + ticket.ticketReserve() + val savedDeal = dealRepository.save(deal) + return DealResponseDto( + barcode = savedDeal.barcode, + sellerName = savedDeal.sellerName, + buyerName = savedDeal.buyerName, + sellingPrice = savedDeal.sellingPrice, + reservedDateTime = savedDeal.reservedDateTime, + dealStatus = savedDeal.dealStatus + ) + } + @Transactional + fun dealEnd(barcode:String, dealEndDto: DealEndDto): DealResponseDto { + val deal = requireNotNull(dealRepository.findByBarcode(barcode)) { "존재하지 않는 거래입니다." } + val ticket = requireNotNull(ticketRepository.findByBarcode(barcode)) { "존재하지 않는 티켓입니다." } + val dealExpiredCheck = deal.reservedTimeExpiredCheck() + if (dealExpiredCheck) { + deal.dealCancel() + ticket.ticketOnSale() + return DealResponseDto( + barcode = deal.barcode, + sellerName = deal.sellerName, + buyerName = deal.buyerName, + sellingPrice = deal.sellingPrice, + reservedDateTime = deal.reservedDateTime, + dealStatus = deal.dealStatus + ) + } + val paymentGateway = paymentGatewayResolver.resolve(dealEndDto.paymentType) + paymentGateway.pay(deal.buyerName, deal.sellerName, deal.sellingPrice) + deal.dealComplete() + ticket.ticketSold() + return DealResponseDto( + barcode = deal.barcode, + sellerName = deal.sellerName, + buyerName = deal.buyerName, + sellingPrice = deal.sellingPrice, + reservedDateTime = deal.reservedDateTime, + dealStatus = deal.dealStatus + ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/domain/enum/DealStatus.kt b/src/main/kotlin/org/example/deal/domain/enum/DealStatus.kt new file mode 100644 index 0000000..3094b42 --- /dev/null +++ b/src/main/kotlin/org/example/deal/domain/enum/DealStatus.kt @@ -0,0 +1,7 @@ +package org.example.deal.domain.enum + +enum class DealStatus { + RESERVED, + COMPLETED, + CANCELLED, +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/domain/enum/PaymentType.kt b/src/main/kotlin/org/example/deal/domain/enum/PaymentType.kt new file mode 100644 index 0000000..b9b7c78 --- /dev/null +++ b/src/main/kotlin/org/example/deal/domain/enum/PaymentType.kt @@ -0,0 +1,6 @@ +package org.example.deal.domain.enum + +enum class PaymentType { + KAKAO, + TOSS +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/domain/model/Deal.kt b/src/main/kotlin/org/example/deal/domain/model/Deal.kt new file mode 100644 index 0000000..a1d997d --- /dev/null +++ b/src/main/kotlin/org/example/deal/domain/model/Deal.kt @@ -0,0 +1,46 @@ +package org.example.deal.domain.model + +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.example.deal.domain.enum.DealStatus +import java.math.BigDecimal +import java.time.LocalDateTime + +@Entity +class Deal( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + val barcode: String, + val sellerName: String, + val buyerName: String, + val reservedDateTime: LocalDateTime = LocalDateTime.now(), + val sellingPrice: BigDecimal, +) { + var dealStatus: DealStatus = DealStatus.RESERVED + private set + + + companion object {} + + init { + require(sellerName != buyerName) { "본인이 등록한 티켓은 구매할 수 없습니다." } + } + + fun dealComplete() { + require(dealStatus == DealStatus.RESERVED) { "예약 상태인 거래만 완료할 수 있습니다." } + dealStatus = DealStatus.COMPLETED + + } + + fun dealCancel() { + require(dealStatus == DealStatus.RESERVED) { "예약 상태인 거래만 취소 할 수 있습니다." } + dealStatus = DealStatus.CANCELLED + } + + fun reservedTimeExpiredCheck(): Boolean { + return dealStatus == DealStatus.RESERVED && reservedDateTime.plusMinutes(10).isBefore(LocalDateTime.now()) + } + +} diff --git a/src/main/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGateway.kt b/src/main/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGateway.kt new file mode 100644 index 0000000..16dd447 --- /dev/null +++ b/src/main/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGateway.kt @@ -0,0 +1,23 @@ +package org.example.deal.infrastructure.api + +import org.example.deal.domain.enum.PaymentType +import org.springframework.stereotype.Component +import org.example.user.infrastructure.repository.UserJpaRepository +import java.math.BigDecimal + + +@Component +class KakaoPaymentGateway( + private val userJpaRepository: UserJpaRepository +) : PaymentGateway { + override fun pay(buyerName: String, sellerName: String, price: BigDecimal) { + val buyer = requireNotNull(userJpaRepository.findByName(buyerName)) { "구매자 정보를 찾을 수 없습니다." } + val seller = requireNotNull(userJpaRepository.findByName(sellerName)) { "판매자 정보를 찾을 수 없습니다." } + buyer.withdraw(price) + seller.deposit(price) + } + + override fun type(): PaymentType { + return PaymentType.KAKAO + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGateway.kt b/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGateway.kt new file mode 100644 index 0000000..1ba2416 --- /dev/null +++ b/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGateway.kt @@ -0,0 +1,9 @@ +package org.example.deal.infrastructure.api + +import org.example.deal.domain.enum.PaymentType +import java.math.BigDecimal + +interface PaymentGateway { + fun pay(buyerName: String, sellerName: String, price: BigDecimal) + fun type(): PaymentType +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGatewayResolver.kt b/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGatewayResolver.kt new file mode 100644 index 0000000..27488e1 --- /dev/null +++ b/src/main/kotlin/org/example/deal/infrastructure/api/PaymentGatewayResolver.kt @@ -0,0 +1,14 @@ +package org.example.deal.infrastructure.api + +import org.example.deal.domain.enum.PaymentType +import org.springframework.stereotype.Component + +@Component +class PaymentGatewayResolver( + private val gateways: List +) { + fun resolve(offerPaymentType: PaymentType): PaymentGateway { + val paymentType = gateways.find { it.type() == offerPaymentType } + return paymentType ?: throw IllegalArgumentException("지원하지 않는 결제 수단입니다.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/infrastructure/api/TossPaymentGateway.kt b/src/main/kotlin/org/example/deal/infrastructure/api/TossPaymentGateway.kt new file mode 100644 index 0000000..2037425 --- /dev/null +++ b/src/main/kotlin/org/example/deal/infrastructure/api/TossPaymentGateway.kt @@ -0,0 +1,22 @@ +package org.example.deal.infrastructure.api + +import org.example.deal.domain.enum.PaymentType +import org.example.user.infrastructure.repository.UserJpaRepository +import org.springframework.stereotype.Component +import java.math.BigDecimal + +@Component +class TossPaymentGateway( + private val userJpaRepository: UserJpaRepository +) : PaymentGateway { + override fun pay(buyerName: String, sellerName: String, price: BigDecimal) { + val buyer = requireNotNull(userJpaRepository.findByName(buyerName)) { "구매자 정보를 찾을 수 없습니다." } + val seller = requireNotNull(userJpaRepository.findByName(sellerName)) { "판매자 정보를 찾을 수 없습니다." } + buyer.withdraw(price) + seller.deposit(price) + } + + override fun type(): PaymentType { + return PaymentType.TOSS + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/deal/infrastructure/repository/DealJpaRepository.kt b/src/main/kotlin/org/example/deal/infrastructure/repository/DealJpaRepository.kt new file mode 100644 index 0000000..427d6e0 --- /dev/null +++ b/src/main/kotlin/org/example/deal/infrastructure/repository/DealJpaRepository.kt @@ -0,0 +1,9 @@ +package org.example.deal.infrastructure.repository + +import org.example.deal.domain.model.Deal +import org.springframework.data.jpa.repository.JpaRepository + +interface DealJpaRepository : JpaRepository { + fun findByBarcode(barcode: String): Deal? + fun findBySellerName(sellerName: String): Deal? +} diff --git a/src/main/kotlin/org/example/deal/presentation/controller/DealController.kt b/src/main/kotlin/org/example/deal/presentation/controller/DealController.kt new file mode 100644 index 0000000..2f35686 --- /dev/null +++ b/src/main/kotlin/org/example/deal/presentation/controller/DealController.kt @@ -0,0 +1,31 @@ +package org.example.deal.presentation.controller + +import org.example.deal.application.dto.DealEndDto +import org.example.deal.application.dto.DealResponseDto +import org.example.deal.application.dto.DealStartDto +import org.example.deal.application.service.DealService +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +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.RestController + +@RestController +@RequestMapping(value = ["/deals"]) +class DealController( + private val dealService: DealService +) { + @PostMapping("") + fun dealStart(@RequestBody dealStartDto: DealStartDto): DealResponseDto { + return dealService.dealStart(dealStartDto) + } + + @PatchMapping("/{barcode}") + fun dealEnd( + @PathVariable("barcode") barcode: String, + @RequestBody dealEndDto: DealEndDto + ): DealResponseDto { + return dealService.dealEnd(barcode, dealEndDto) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/error/controller/ErrorResponse.kt b/src/main/kotlin/org/example/error/controller/ErrorResponse.kt new file mode 100644 index 0000000..6da70fe --- /dev/null +++ b/src/main/kotlin/org/example/error/controller/ErrorResponse.kt @@ -0,0 +1,6 @@ +package org.example.error.controller + +data class ErrorResponse( + val message: String, + val status: Int +) \ No newline at end of file diff --git a/src/main/kotlin/org/example/error/controller/GlobalExceptionHandler.kt b/src/main/kotlin/org/example/error/controller/GlobalExceptionHandler.kt new file mode 100644 index 0000000..d0a23b9 --- /dev/null +++ b/src/main/kotlin/org/example/error/controller/GlobalExceptionHandler.kt @@ -0,0 +1,19 @@ +package org.example.error.controller + +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgument(e: IllegalArgumentException): ResponseEntity { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + ErrorResponse( + message = e.message ?: "잘못된 요청입니다.", status = 400 + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/application/dto/TicketCreationDto.kt b/src/main/kotlin/org/example/ticket/application/dto/TicketCreationDto.kt index 29339a3..34114ba 100644 --- a/src/main/kotlin/org/example/ticket/application/dto/TicketCreationDto.kt +++ b/src/main/kotlin/org/example/ticket/application/dto/TicketCreationDto.kt @@ -1,5 +1,6 @@ package org.example.ticket.application.dto data class TicketCreationDto( - val varCode: String + val sellerName: String, + val barcode: String ) diff --git a/src/main/kotlin/org/example/ticket/application/dto/TicketResponseDto.kt b/src/main/kotlin/org/example/ticket/application/dto/TicketResponseDto.kt new file mode 100644 index 0000000..c6b4aa3 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/application/dto/TicketResponseDto.kt @@ -0,0 +1,14 @@ +package org.example.ticket.application.dto + +import org.example.ticket.domain.enum.TicketStatus +import java.math.BigDecimal +import java.time.LocalDateTime + +data class TicketResponseDto( + val barcode: String, + val seller: String, + val originalPrice: BigDecimal, + val sellingPrice: BigDecimal?, + val expirationDate: LocalDateTime, + val ticketStatus: TicketStatus +) diff --git a/src/main/kotlin/org/example/ticket/application/dto/TicketSellingPriceOfferDto.kt b/src/main/kotlin/org/example/ticket/application/dto/TicketSellingPriceOfferDto.kt new file mode 100644 index 0000000..e8e83a8 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/application/dto/TicketSellingPriceOfferDto.kt @@ -0,0 +1,8 @@ +package org.example.ticket.application.dto + +import java.math.BigDecimal + +data class TicketSellingPriceOfferDto( + val sellerName: String, + val sellingPrice: BigDecimal, +) \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/application/service/TicketService.kt b/src/main/kotlin/org/example/ticket/application/service/TicketService.kt index 1e731a3..413ada1 100644 --- a/src/main/kotlin/org/example/ticket/application/service/TicketService.kt +++ b/src/main/kotlin/org/example/ticket/application/service/TicketService.kt @@ -1,27 +1,55 @@ package org.example.ticket.application.service +import jakarta.transaction.Transactional import org.example.ticket.application.dto.TicketCreationDto -import org.example.ticket.domain.model.Ticket -import org.example.ticket.infra.api.TicketApiClient -import org.example.ticket.infra.repository.TicketJpaRepository +import org.example.ticket.application.dto.TicketResponseDto +import org.example.ticket.application.dto.TicketSellingPriceOfferDto +import org.example.ticket.infrastructure.repository.TicketJpaRepository +import org.example.ticket.infrastructure.api.TicketApiClientResolver import org.springframework.stereotype.Service @Service class TicketService( - private val ticketApiClient: List, + private val ticketApiClientResolver: TicketApiClientResolver, private val ticketRepository: TicketJpaRepository ) { - fun createTicket(ticketCreationDto: TicketCreationDto) { - val ticketType = Ticket.ticketType(ticketCreationDto.varCode) - val apiClient = ticketApiClient.first { it.type(ticketCreationDto.varCode) == ticketType } - - val ticketResponseDto = apiClient.getTicket(ticketCreationDto.varCode) - val ticket = ticketResponseDto.toTicket() - - ticketRepository.save(ticket) + @Transactional + fun createTicket(ticketCreationDto: TicketCreationDto): TicketResponseDto { + val apiClient = ticketApiClientResolver.resolve(ticketCreationDto.barcode) + val ticketResponseDto = apiClient.getTicket(ticketCreationDto.barcode) + val ticket = ticketRepository.save( + ticketResponseDto.toTicket( + ticketCreationDto.barcode, + ticketCreationDto.sellerName, + apiClient.type(), + ) + ) + return TicketResponseDto( + barcode = ticket.barcode, + seller = ticket.sellerName, + originalPrice = ticket.originalPrice, + sellingPrice = ticket.sellingPrice, + expirationDate = ticket.expirationDateTime, + ticketStatus = ticket.ticketStatus, + ) } - - fun applySellerOfferPrice() { - // TODO : 구현 + @Transactional + fun applySellerOfferPrice(barcode: String, ticketSellingPriceOfferDto: TicketSellingPriceOfferDto): TicketResponseDto { + val ticket = requireNotNull(ticketRepository.findByBarcode(barcode)){ + "해당 티켓 정보가 존재하지 않습니다." + } + ticket.applySellerOfferPrice( + ticketSellingPriceOfferDto.sellerName, + ticketSellingPriceOfferDto.sellingPrice + ) + return TicketResponseDto( + barcode = ticket.barcode, + seller = ticket.sellerName, + originalPrice = ticket.originalPrice, + sellingPrice = ticket.sellingPrice, + expirationDate = ticket.expirationDateTime, + ticketStatus = ticket.ticketStatus, + ) } -} + +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/domain/enumeration/TicketStatus.kt b/src/main/kotlin/org/example/ticket/domain/enum/TicketStatus.kt similarity index 62% rename from src/main/kotlin/org/example/ticket/domain/enumeration/TicketStatus.kt rename to src/main/kotlin/org/example/ticket/domain/enum/TicketStatus.kt index 2e6260c..715cb8f 100644 --- a/src/main/kotlin/org/example/ticket/domain/enumeration/TicketStatus.kt +++ b/src/main/kotlin/org/example/ticket/domain/enum/TicketStatus.kt @@ -1,4 +1,4 @@ -package org.example.ticket.domain.enumeration +package org.example.ticket.domain.enum enum class TicketStatus { ON_SALE, diff --git a/src/main/kotlin/org/example/ticket/domain/enum/TicketType.kt b/src/main/kotlin/org/example/ticket/domain/enum/TicketType.kt new file mode 100644 index 0000000..a44ee3a --- /dev/null +++ b/src/main/kotlin/org/example/ticket/domain/enum/TicketType.kt @@ -0,0 +1,7 @@ +package org.example.ticket.domain.enum + +enum class TicketType { + MELON, + NOL, + MOL +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/domain/enumeration/TicketType.kt b/src/main/kotlin/org/example/ticket/domain/enumeration/TicketType.kt deleted file mode 100644 index ad89680..0000000 --- a/src/main/kotlin/org/example/ticket/domain/enumeration/TicketType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.example.ticket.domain.enumeration - -enum class TicketType { - NOL, - MELON -} diff --git a/src/main/kotlin/org/example/ticket/domain/model/Ticket.kt b/src/main/kotlin/org/example/ticket/domain/model/Ticket.kt index de898a3..230fe6d 100644 --- a/src/main/kotlin/org/example/ticket/domain/model/Ticket.kt +++ b/src/main/kotlin/org/example/ticket/domain/model/Ticket.kt @@ -1,43 +1,92 @@ package org.example.ticket.domain.model -import org.example.ticket.domain.enumeration.TicketStatus -import org.example.ticket.domain.enumeration.TicketType +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import org.example.ticket.domain.enum.TicketStatus +import org.example.ticket.domain.enum.TicketType import java.math.BigDecimal import java.time.LocalDateTime +@Entity class Ticket( - val id: Long? = null, - val expirationAt: LocalDateTime, - val originalPrice: BigDecimal, - private var sellingPrice: BigDecimal? = null, - val ticketStatus: TicketStatus = TicketStatus.ON_SALE, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, // ? => DB에 넘기기전에 id를 안넣으려고 (자동생성) + val barcode: String, + val expirationDateTime: LocalDateTime, + val originalPrice: BigDecimal, // BigDecimal => 10진수로 저장(소수점 계산을 위해) + val sellerName: String, + val ticketType: TicketType ) { + var sellingPrice: BigDecimal? = null + private set + var ticketStatus: TicketStatus = TicketStatus.ON_SALE + private set + companion object { - val ACTIVATION_DEAD_LINE: LocalDateTime = LocalDateTime.now().plusHours(3L) - private val HALF = BigDecimal("0.5") - - fun ticketType(varCode: String): TicketType { - return if (varCode.contains("MELON")) { - TicketType.MELON - } else { - TicketType.NOL - } - } + private val BARCODE_LENGTH = 8; + private val PERFORMANCE_DAY_SALE_POLICY = BigDecimal("0.5") + } init { - require(expirationAt.isAfter(ACTIVATION_DEAD_LINE)) { "ticket cannot be registered after expiration" } + require(barcode.length == BARCODE_LENGTH) { "바코드는 8자리여야 합니다." } + ticketTimeCheck() + } + + private fun getTicketDeadLine(): LocalDateTime { + return expirationDateTime.minusHours(1) } - fun applySellerOfferPrice(offerPrice: BigDecimal) { - val isPerformanceDay = LocalDateTime.now().toLocalDate() == expirationAt.toLocalDate() - if (isPerformanceDay) { - val maxAllowedPrice = originalPrice.multiply(HALF) - require(offerPrice <= maxAllowedPrice) { - "on performance day, selling price must be <= 50% of regular price" - } + private fun ticketTimeCheck() { + val now = LocalDateTime.now() + val deadLine = getTicketDeadLine() + require(now.isBefore(deadLine)) { + "공연 시작 1시간 전까지만 등록할 수 있습니다." } + } + + fun applySellerOfferPrice(offerSellerName: String, offerPrice: BigDecimal) { + require(sellerName == offerSellerName) { "티켓에 등록된 판매자가 아닙니다." } + require(offerPrice >= BigDecimal.ZERO) { "판매가격에 음수는 입력할 수 없습니다." } + require(offerPrice <= originalPrice) { "티켓의 가격은 정가를 초과할 수 없습니다." } + if (ticketStatus != TicketStatus.SOLD && LocalDateTime.now().isAfter(getTicketDeadLine())) { + ticketExpired() + } + require(ticketStatus == TicketStatus.ON_SALE) { "판매중인 제품만 가격을 수정할 수 있습니다." } + val isPerformanceDateTime = LocalDateTime.now().toLocalDate() == expirationDateTime.toLocalDate() + if (isPerformanceDateTime) { + val maxAllowedPrice = originalPrice.multiply(PERFORMANCE_DAY_SALE_POLICY) + require(offerPrice <= maxAllowedPrice) { "공연 당일에는 가격을 정가의 50% 이하로만 설정할 수 있습니다." } + } + sellingPrice = offerPrice + } - this.sellingPrice = offerPrice + + fun ticketOnSale() { + require(ticketStatus == TicketStatus.RESERVED) { "예약중인 티켓만 판매중으로 되돌릴 수 있습니다." } + ticketStatus = TicketStatus.ON_SALE + } + + fun ticketReserve() { + require(ticketStatus == TicketStatus.ON_SALE) { "판매중인 티켓만 예약할 수 있습니다" } + ticketStatus = TicketStatus.RESERVED + } + + fun ticketSold() { + require(ticketStatus == TicketStatus.RESERVED) { "예약중인 티켓만 판매완료할 수 있습니다." } + ticketStatus = TicketStatus.SOLD + } + + fun ticketExpired() { + require(ticketStatus != TicketStatus.SOLD) { "판매완료된 티켓은 만료할 수 없습니다." } + val now = LocalDateTime.now() + val deadLine = getTicketDeadLine() + require(now.isAfter(deadLine)) { "만료되지 않은 티켓입니다." } + ticketStatus = TicketStatus.EXPIRED } } + + + diff --git a/src/main/kotlin/org/example/ticket/infra/api/MelonTicketApiClient.kt b/src/main/kotlin/org/example/ticket/infra/api/MelonTicketApiClient.kt deleted file mode 100644 index 6737a08..0000000 --- a/src/main/kotlin/org/example/ticket/infra/api/MelonTicketApiClient.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.example.ticket.infra.api - -import org.example.ticket.domain.enumeration.TicketType -import org.example.ticket.infra.dto.TicketApiResponseDto -import org.springframework.stereotype.Component -import java.math.BigDecimal -import java.time.LocalDateTime - -@Component -class MelonTicketApiClient : TicketApiClient { - override fun getTicket(varCode: String): TicketApiResponseDto { - return TicketApiResponseDto( - LocalDateTime.now().plusDays(20), BigDecimal.valueOf(10000) - ) - } - - override fun type(varCode: String): TicketType { - return TicketType.MELON - } -} diff --git a/src/main/kotlin/org/example/ticket/infra/api/NolTicketClient.kt b/src/main/kotlin/org/example/ticket/infra/api/NolTicketClient.kt deleted file mode 100644 index 0b289cc..0000000 --- a/src/main/kotlin/org/example/ticket/infra/api/NolTicketClient.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.example.ticket.infra.api - -import org.example.ticket.domain.enumeration.TicketType -import org.example.ticket.infra.dto.TicketApiResponseDto -import org.springframework.stereotype.Component -import java.math.BigDecimal -import java.time.LocalDateTime - -@Component -class NolTicketClient : TicketApiClient { - override fun getTicket(varCode: String): TicketApiResponseDto { - return TicketApiResponseDto(LocalDateTime.now().plusDays(10), BigDecimal.valueOf(20000)) - } - - override fun type(varCode: String): TicketType { - return TicketType.NOL - } -} diff --git a/src/main/kotlin/org/example/ticket/infra/api/TicketApiClient.kt b/src/main/kotlin/org/example/ticket/infra/api/TicketApiClient.kt deleted file mode 100644 index 3e8e3c4..0000000 --- a/src/main/kotlin/org/example/ticket/infra/api/TicketApiClient.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.example.ticket.infra.api - -import org.example.ticket.domain.enumeration.TicketType -import org.example.ticket.infra.dto.TicketApiResponseDto - -interface TicketApiClient { - fun getTicket(varCode: String) : TicketApiResponseDto - fun type(varCode: String) : TicketType -} diff --git a/src/main/kotlin/org/example/ticket/infra/dto/TicketApiResponseDto.kt b/src/main/kotlin/org/example/ticket/infra/dto/TicketApiResponseDto.kt deleted file mode 100644 index 855c1af..0000000 --- a/src/main/kotlin/org/example/ticket/infra/dto/TicketApiResponseDto.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.example.ticket.infra.dto - -import org.example.ticket.domain.model.Ticket -import java.math.BigDecimal -import java.time.LocalDateTime - -data class TicketApiResponseDto( - val performanceDate: LocalDateTime, - val price: BigDecimal -) { - fun toTicket(): Ticket { - return Ticket( - expirationAt = performanceDate, - originalPrice = price, - ) - } -} diff --git a/src/main/kotlin/org/example/ticket/infra/repository/TicketJpaRepository.kt b/src/main/kotlin/org/example/ticket/infra/repository/TicketJpaRepository.kt deleted file mode 100644 index 235c684..0000000 --- a/src/main/kotlin/org/example/ticket/infra/repository/TicketJpaRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.example.ticket.infra.repository - -import org.example.ticket.domain.model.Ticket -import org.springframework.data.jpa.repository.JpaRepository - -interface TicketJpaRepository: JpaRepository { -} diff --git a/src/main/kotlin/org/example/ticket/infrastructure/api/MelonTicketApiClient.kt b/src/main/kotlin/org/example/ticket/infrastructure/api/MelonTicketApiClient.kt new file mode 100644 index 0000000..b857f42 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/api/MelonTicketApiClient.kt @@ -0,0 +1,22 @@ +package org.example.ticket.infrastructure.api + +import org.example.ticket.domain.enum.TicketType +import org.example.ticket.infrastructure.dto.TicketApiResponseDto +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDateTime + +@Component +class MelonTicketApiClient : TicketApiClient { + override fun getTicket(barcode: String): TicketApiResponseDto { + return TicketApiResponseDto( + + performanceDateTime = LocalDateTime.now().plusDays(3), + price = BigDecimal.valueOf(10000) + ) + } + + override fun type(): TicketType { + return TicketType.MELON + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/infrastructure/api/MolTicketApiClient.kt b/src/main/kotlin/org/example/ticket/infrastructure/api/MolTicketApiClient.kt new file mode 100644 index 0000000..57b0238 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/api/MolTicketApiClient.kt @@ -0,0 +1,21 @@ +package org.example.ticket.infrastructure.api + +import org.example.ticket.domain.enum.TicketType +import org.example.ticket.infrastructure.dto.TicketApiResponseDto +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDateTime + +@Component +class MolTicketApiClient : TicketApiClient { + override fun getTicket(barcode: String): TicketApiResponseDto { + return TicketApiResponseDto( + performanceDateTime = LocalDateTime.now().plusDays(5), + price = BigDecimal.valueOf(30000) + ) + } + + override fun type(): TicketType { + return TicketType.MOL + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/infrastructure/api/NolTicketApiClient.kt b/src/main/kotlin/org/example/ticket/infrastructure/api/NolTicketApiClient.kt new file mode 100644 index 0000000..050f582 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/api/NolTicketApiClient.kt @@ -0,0 +1,21 @@ +package org.example.ticket.infrastructure.api + +import org.example.ticket.domain.enum.TicketType +import org.example.ticket.infrastructure.dto.TicketApiResponseDto +import org.springframework.stereotype.Component +import java.math.BigDecimal +import java.time.LocalDateTime + +@Component +class NolTicketApiClient : TicketApiClient { + override fun getTicket(barcode: String): TicketApiResponseDto { + return TicketApiResponseDto( + performanceDateTime = LocalDateTime.now().plusDays(5), + price = BigDecimal.valueOf(20000) + ) + } + + override fun type(): TicketType { + return TicketType.NOL + } +} diff --git a/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClient.kt b/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClient.kt new file mode 100644 index 0000000..0992793 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClient.kt @@ -0,0 +1,10 @@ +package org.example.ticket.infrastructure.api + +import org.example.ticket.domain.enum.TicketType +import org.example.ticket.infrastructure.dto.TicketApiResponseDto + +interface TicketApiClient { + fun getTicket(barcode: String): TicketApiResponseDto + fun type(): TicketType + +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClientResolver.kt b/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClientResolver.kt new file mode 100644 index 0000000..86c116c --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/api/TicketApiClientResolver.kt @@ -0,0 +1,22 @@ +package org.example.ticket.infrastructure.api + +import org.example.ticket.domain.enum.TicketType +import org.springframework.stereotype.Component + +@Component +class TicketApiClientResolver( + private val ticketApiClients: List +) { + fun resolve(barcode: String): TicketApiClient { + val ticketType = resolveTicketType(barcode) + return ticketApiClients.first { it.type() == ticketType } + } + + private fun resolveTicketType(barcode: String): TicketType { + return when { + barcode.all { it.isLetter() } -> TicketType.MELON + barcode.all { it.isDigit() } -> TicketType.NOL + else -> TicketType.MOL + } + } +} diff --git a/src/main/kotlin/org/example/ticket/infrastructure/dto/TicketApiResponseDto.kt b/src/main/kotlin/org/example/ticket/infrastructure/dto/TicketApiResponseDto.kt new file mode 100644 index 0000000..b2a1b61 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/dto/TicketApiResponseDto.kt @@ -0,0 +1,22 @@ +package org.example.ticket.infrastructure.dto + +import org.example.ticket.domain.enum.TicketType +import org.example.ticket.domain.model.Ticket +import java.math.BigDecimal +import java.time.LocalDateTime + +data class TicketApiResponseDto( + val performanceDateTime: LocalDateTime, + val price: BigDecimal +) { + fun toTicket(barcode: String, sellerId: String, ticketType: TicketType): Ticket { + return Ticket( + barcode = barcode, + ticketType = ticketType, + sellerName = sellerId, + expirationDateTime = performanceDateTime, + originalPrice = price + ) + } +} + diff --git a/src/main/kotlin/org/example/ticket/infrastructure/repository/TicketJpaRepository.kt b/src/main/kotlin/org/example/ticket/infrastructure/repository/TicketJpaRepository.kt new file mode 100644 index 0000000..77a14d8 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/infrastructure/repository/TicketJpaRepository.kt @@ -0,0 +1,123 @@ +package org.example.ticket.infrastructure.repository + +import org.example.ticket.domain.model.Ticket +import org.springframework.data.jpa.repository.JpaRepository + +interface TicketJpaRepository : JpaRepository { + fun findByBarcode(barcode: String): Ticket? // ? 넣어야 null 처리되서 조회 안될때 에러가 안남 +} + +/* JPA 동작 방식 + 스프링이 JPA 의존성을 보고 어떻게 이 라이브러리코드가 동작하도록 끌어오는가? + 1. 의존성에 JPA가 존재하는걸 확인 + 2. JpaRepository 상속한 인터페이스를 찾음 + 3. 런타임 메모리에 CRUD 메서드가 선언된 클래스 자동 생성 + 4. 생성된 클래스 자바빈 등록 + 5. JpaRepository를 상속받은 클래스(작성한 클래스)를 통해 CRUD가능 +*/ + +/* 이해 안가던 점 + 1. Repository를 CrudRepository가 확장 + 2. CrudRepository를 ListCrudRepository가 확장 + 3. JpaRepository가 ListCrudRepository를 확장 + + A : JpaRepository -> TicketJpaRepository + B : JpaRepository -> JpaRepositoryImplementation -> SimpleJpaRepository + + SimpleRepository(라이브러리)/TicketJpaRepository(내구현) + + 1. TicketJpaRepository.save를 타고올라가면 CrudRepository.save가 나옴 + 2. CrudRepository.save를 구현한건 SimpleJpaRepository임 + 3. ? 그런데 우리가 쓰는건 TicketJpaRepository인데 SimpleJpaRepository.save가 어떻게 연결되지? + (TicketJpaRepository.save는 선언안했는데?) + + => 스프링이 프록시를 자동 생성해주기 때문 + 최상위 부터 동작을 추적하면 + + 1. Spring Boot Auto Configuration + => "JPA 의존성 있네? @EnableJpaRepositories 자동 활성화" + + 2. JpaRepositoriesRegistrar + => "JpaRepository 상속한 인터페이스 스캔해" → TicketJpaRepository 발견 + + 3. JpaRepositoryFactoryBean (인터페이스당 하나 생성) + => "TicketJpaRepository를 처리할 팩토리 만들자" + + 4. JpaRepositoryFactory + => SimpleJpaRepository 생성 + => 프록시로 감싸기 + => Bean 등록 + + => 결론 : TicketJpaRepository.save가 SimpleJpaRepository.save로 호출되는건 단순하게 스프링 내부 동작임 + SimpleJpaRepository에 보면 라고 선언되어 있는데 + * Default implementation of the {@link org.springframework.data.repository.CrudRepository} interface. This will offer + * you a more sophisticated interface than the plain {@link EntityManager} . + 이렇게 SimpleJpaRepository에가 CrudRepository의 기본 구현체라고 기입되어 있는데, + => 프록시가 자동으로 연결해준단 말임 + => extends(확장/상속)가 아니라 delegation(위임)하는 형태 + => 이해하기 어려운 부분은 작성하는 코드에 보이는게 아니라 런타임 구현체를 스프링이 생성한다고 보는것 + => 본인 코드만 보면 이 동작을 절대 알 수 없음 => Spring Doc를 읽읍시다 + +*/ + + +/* +JPA 내부동작 +JPA의 핵심은 "객체와 DB 테이블을 매핑해서, SQL을 직접 안 짜고 객체로 DB를 다루게 해주는 것" +TicketJpaRepository → JpaRepository → ListCrudRepository → CrudRepository +전부 인터페이스 → 껍데기만 있음 → save()의 실제 코드가 없음 +Spring이 해주는 건 이 껍데기에 SimpleJpaRepository라는 구현체를 연결해주는 것입니다. + 1. 매핑 (앱 시작 시) + Ticket 클래스를 분석 (리플렉션) + → @Entity 확인 → "이건 DB 테이블이구나" + → 클래스명 Ticket → 테이블명 ticket + → 필드 barcode: String → 컬럼 barcode VARCHAR + → 필드 originalPrice: BigDecimal → 컬럼 original_price DECIMAL + → 필드 id: Long + @Id → Primary Key + + 결과: 객체 ↔ 테이블 매핑 정보를 메모리에 보관 + + 2. 영속성 컨텍스트 (핵심) + + JPA는 객체와 DB 사이에 중간 저장소를 둡니다: + + 우리 코드 ↔ 영속성 컨텍스트(메모리) ↔ DB + + 모든 동작이 이 영속성 컨텍스트를 거칩니다: + + save(ticket) → 영속성 컨텍스트에 등록 → 커밋 시 INSERT + findById(1L) → 영속성 컨텍스트에 있으면 그거 반환 (DB 안 감) + → 없으면 DB 조회 → 영속성 컨텍스트에 저장 → 반환 + delete(ticket) → 영속성 컨텍스트에서 삭제 표시 → 커밋 시 DELETE + + 3. 변경 감지 (Dirty Checking) + + 이게 JPA의 가장 강력한 기능입니다: + + val ticket = ticketRepository.findById(1L) // DB에서 조회 + ticket.ticketStatus = TicketStatus.SOLD // 필드만 바꿈 + // save() 안 해도 됨! + // 트랜잭션 커밋 시 JPA가 "어? 값이 바뀌었네" → UPDATE 자동 실행 + + 조회 시: 원본 스냅샷을 저장해둠 {status: ON_SALE} + 커밋 시: 현재 상태와 스냅샷 비교 {status: SOLD} vs {status: ON_SALE} + → "status 바뀌었네" → UPDATE ticket SET status = 'SOLD' WHERE id = 1 + + 4. 전체 흐름 정리 + + 트랜잭션 시작 + ↓ + 조회/저장/수정/삭제 → 전부 영속성 컨텍스트에서 처리 + ↓ + 트랜잭션 커밋 + ↓ + 영속성 컨텍스트와 DB의 차이를 비교 + ↓ + 필요한 SQL만 생성해서 DB에 실행 (INSERT, UPDATE, DELETE) + ↓ + 영속성 컨텍스트 초기화 + + 결국 JPA는 **"DB를 직접 건드리지 말고, 객체만 다뤄. 나머지는 내가 알아서 SQL로 바꿔줄게"**가 전부입니다. + 영속성 컨텍스트가 그 중간다리 역할을 하는 거고요. + + */ \ No newline at end of file diff --git a/src/main/kotlin/org/example/ticket/presentation/TicketController.kt b/src/main/kotlin/org/example/ticket/presentation/TicketController.kt deleted file mode 100644 index 4dff4a0..0000000 --- a/src/main/kotlin/org/example/ticket/presentation/TicketController.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.example.ticket.presentation - -import org.example.ticket.application.dto.TicketCreationDto -import org.example.ticket.application.service.TicketService -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController - -@RestController -class TicketController( - private val ticketService: TicketService -) { - @PostMapping("/tickets") - fun registerTicket( - @RequestBody ticketCreationDto: TicketCreationDto - ) { - ticketService.createTicket(ticketCreationDto) - } -} diff --git a/src/main/kotlin/org/example/ticket/presentation/controller/TicketController.kt b/src/main/kotlin/org/example/ticket/presentation/controller/TicketController.kt new file mode 100644 index 0000000..c1d3104 --- /dev/null +++ b/src/main/kotlin/org/example/ticket/presentation/controller/TicketController.kt @@ -0,0 +1,31 @@ +package org.example.ticket.presentation.controller + +import org.example.ticket.application.dto.TicketCreationDto +import org.example.ticket.application.dto.TicketResponseDto +import org.example.ticket.application.dto.TicketSellingPriceOfferDto +import org.example.ticket.application.service.TicketService +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +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.RestController + +@RestController +@RequestMapping("/tickets") +class TicketController( + private val ticketService: TicketService +) { + @PostMapping("") + fun createTicket(@RequestBody requestDto: TicketCreationDto): TicketResponseDto { + return ticketService.createTicket(requestDto) + } + + @PatchMapping("/{barcode}/price") + fun applySellerOfferPrice( + @PathVariable("barcode") barcode: String, + @RequestBody dto: TicketSellingPriceOfferDto + ): TicketResponseDto { + return ticketService.applySellerOfferPrice(barcode, dto) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/user/domain/enum/UserRole.kt b/src/main/kotlin/org/example/user/domain/enum/UserRole.kt new file mode 100644 index 0000000..e97da0e --- /dev/null +++ b/src/main/kotlin/org/example/user/domain/enum/UserRole.kt @@ -0,0 +1,6 @@ +package org.example.user.domain.enum + +enum class UserRole { + SELLER, + BUYER, +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/user/domain/model/User.kt b/src/main/kotlin/org/example/user/domain/model/User.kt new file mode 100644 index 0000000..13604b2 --- /dev/null +++ b/src/main/kotlin/org/example/user/domain/model/User.kt @@ -0,0 +1,26 @@ +package org.example.user.domain.model + + +import org.example.user.domain.enum.UserRole +import java.math.BigDecimal + +class User( + val id: Long? = null, + val name: String, + val role: UserRole, + var money: BigDecimal = BigDecimal.ZERO +) { + fun deposit(amount: BigDecimal) { + require(amount > BigDecimal.ZERO) { "입금액은 0보다 커야 합니다." } + require(amount >= money) { "잔액이 부족합니다." } + money = money.add(amount) + } + + fun withdraw(amount: BigDecimal) { + require(amount > BigDecimal.ZERO) { "출금액은 0보다 커야 합니다." } + require(money >= amount) { "잔액이 부족합니다." } + money = money.subtract(amount) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/org/example/user/infrastructure/repository/UserJpaRepository.kt b/src/main/kotlin/org/example/user/infrastructure/repository/UserJpaRepository.kt new file mode 100644 index 0000000..5c2d381 --- /dev/null +++ b/src/main/kotlin/org/example/user/infrastructure/repository/UserJpaRepository.kt @@ -0,0 +1,18 @@ +package org.example.user.infrastructure.repository + +import org.example.user.domain.enum.UserRole +import org.example.user.domain.model.User +import org.springframework.stereotype.Repository +import java.math.BigDecimal + +@Repository +class UserJpaRepository { + private val users = mutableListOf( + User(id = 1, name = "판매자1", role = UserRole.SELLER), + User(id = 2, name = "판매자2", role = UserRole.SELLER), + User(id = 3, name = "구매자1", role = UserRole.BUYER).apply { deposit(BigDecimal(10000)) }, + User(id = 4, name = "구매자2", role = UserRole.BUYER).apply { deposit(BigDecimal(50000)) }, + ) + + fun findByName(name: String): User? = users.find { it.name == name } +} \ No newline at end of file diff --git a/src/test/kotlin/org/example/ticket/TicketApplicationTests.kt b/src/test/kotlin/org/example/SecondChanceApplicationTests.kt similarity index 73% rename from src/test/kotlin/org/example/ticket/TicketApplicationTests.kt rename to src/test/kotlin/org/example/SecondChanceApplicationTests.kt index aca8487..62faf02 100644 --- a/src/test/kotlin/org/example/ticket/TicketApplicationTests.kt +++ b/src/test/kotlin/org/example/SecondChanceApplicationTests.kt @@ -1,10 +1,10 @@ -package org.example.ticket +package org.example import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest @SpringBootTest -class TicketApplicationTests { +class SecondChanceApplicationTests { @Test fun contextLoads() { diff --git a/src/test/kotlin/org/example/deal/domain/DealTest.kt b/src/test/kotlin/org/example/deal/domain/DealTest.kt new file mode 100644 index 0000000..15cc70f --- /dev/null +++ b/src/test/kotlin/org/example/deal/domain/DealTest.kt @@ -0,0 +1,63 @@ +package org.example.deal.domain + +import org.example.deal.domain.model.Deal +import org.example.deal.domain.enum.DealStatus +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.assertj.core.api.Assertions.assertThat +import java.math.BigDecimal +import java.time.LocalDateTime + +class DealTest { + + @Test + fun `판매자는 자신의 티켓을 구매할 수 없다`() { + assertThrows { + Deal( + barcode = "ABCDEFGH", + sellerName = "판매자1", + buyerName = "판매자1", + sellingPrice = BigDecimal(10000) + ) + } + } + + @Test + fun `거래 생성 시 상태는 RESERVED이다`() { + val deal = Deal( + barcode = "ABCDEFGH", + sellerName = "판매자1", + buyerName = "구매자1", + sellingPrice = BigDecimal(10000) + ) + assertThat(deal.dealStatus).isEqualTo(DealStatus.RESERVED) + } + + + @Test + fun `예약 상태인 거래만 완료할 수 있다`() { + val deal = Deal( + barcode = "ABCDEFGH", + sellerName = "판매자1", + buyerName = "구매자1", + sellingPrice = BigDecimal(10000), + ) + deal.dealComplete() // RESERVED → COMPLETED + assertThrows { + deal.dealComplete() // COMPLETED → 💥 예외 + } + } + + @Test + fun `10분이 지나면 예약이 만료된다`() { + val deal = Deal( + barcode = "ABCDEFGH", + sellerName = "판매자1", + buyerName = "구매자1", + sellingPrice = BigDecimal(10000), + reservedDateTime = LocalDateTime.now().minusMinutes(11) // 11분 전 + ) + assertThat(deal.reservedTimeExpiredCheck()).isTrue() + } +} + diff --git a/src/test/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGatewayTest.kt b/src/test/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGatewayTest.kt new file mode 100644 index 0000000..b577d94 --- /dev/null +++ b/src/test/kotlin/org/example/deal/infrastructure/api/KakaoPaymentGatewayTest.kt @@ -0,0 +1,48 @@ + +package org.example.deal.infrastructure.api + +import org.assertj.core.api.Assertions.assertThat +import org.example.user.domain.enum.UserRole +import org.example.user.domain.model.User +import org.example.user.infrastructure.repository.UserJpaRepository +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import java.math.BigDecimal + +@ExtendWith(MockitoExtension::class) +class KakaoPaymentGatewayTest { + + @Mock + private lateinit var userJpaRepository: UserJpaRepository + + @InjectMocks + private lateinit var kakaoPaymentGateway: KakaoPaymentGateway + + @Test + fun `결제에 성공한다`() { + val buyer = User(name = "구매자1", role = UserRole.BUYER).apply { + deposit(BigDecimal(50000)) } + val seller = User(name = "판매자1", role = UserRole.SELLER) + + `when`(userJpaRepository.findByName("구매자1")).thenReturn(buyer) + `when`(userJpaRepository.findByName("판매자1")).thenReturn(seller) + + kakaoPaymentGateway.pay("구매자1", "판매자1", BigDecimal(10000)) + assertThat(buyer.money).isEqualTo(BigDecimal(40000)) + assert(seller.money == BigDecimal(10000)) + } + + @Test + fun `존재하지 않는 구매자일경우 구매할 수 없다`() { + // Mock은 기본적으로 null을 반환하므로 + // findByName("없는유저") → null → requireNotNull에서 예외 발생 + assertThrows { + kakaoPaymentGateway.pay("없는유저", "판매자1", BigDecimal(10000)) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/example/ticket/domain/TicketTest.kt b/src/test/kotlin/org/example/ticket/domain/TicketTest.kt index 55dee9c..6e17926 100644 --- a/src/test/kotlin/org/example/ticket/domain/TicketTest.kt +++ b/src/test/kotlin/org/example/ticket/domain/TicketTest.kt @@ -2,46 +2,144 @@ package org.example.ticket.domain import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.SoftAssertions.assertSoftly +import org.example.ticket.domain.enum.TicketStatus +import org.example.ticket.domain.enum.TicketType import org.example.ticket.domain.model.Ticket +import org.example.user.domain.enum.UserRole +import org.example.user.domain.model.User import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.math.BigDecimal import java.time.LocalDateTime class TicketTest { + private fun createUser( + name: String = "테스트유저", + role: UserRole = UserRole.BUYER, + ): User = User(name = name, role = role) + + private fun createTicket( + barcode: String = "ABCDEFGH", + sellerName: String = "Seller A", + expirationDateTime: LocalDateTime = LocalDateTime.now().plusHours(2), + originalPrice: BigDecimal = BigDecimal.TEN, + ticketType: TicketType = TicketType.MELON + ): Ticket { + return Ticket( + barcode = barcode, + sellerName = sellerName, + expirationDateTime = expirationDateTime, + originalPrice = originalPrice, + ticketType = ticketType + ) + } + @Test fun `티켓_만들기`() { - val ticket = Ticket(1L, LocalDateTime.now(), BigDecimal.TEN) - + val ticket = createTicket(); assertSoftly { assertThat(ticket.id).isEqualTo(1L) - assertThat(ticket.expirationAt).isNotNull + assertThat(ticket.ticketStatus).isEqualTo(TicketStatus.ON_SALE) + assertThat(ticket.expirationDateTime).isNotNull assertThat(ticket.originalPrice).isEqualTo(BigDecimal.TEN) } } + @Test + fun `바코드가 8자리가 아니면 에러`() { + assertThrows { + val ticket = createTicket(barcode= "ABC") + } + } + + @Test fun `티켓 유효기간이 등록 기준 시간보다 늦으면 예외이다`() { assertThrows { - Ticket(expirationAt = Ticket.ACTIVATION_DEAD_LINE.minusSeconds(1), originalPrice = BigDecimal.TEN) + createTicket(expirationDateTime = LocalDateTime.now().plusHours(1).minusSeconds(1),) } } @Test fun `공연 당일에는 가격을 정가의 50% 이하로만 설정 할 수 있다`() { - val ticketPrice = BigDecimal.TEN - val ticket = Ticket(1L, Ticket.ACTIVATION_DEAD_LINE.plusSeconds(1), ticketPrice) - + val ticket = createTicket(originalPrice = BigDecimal(10000)) assertThrows { - ticket.applySellerOfferPrice(BigDecimal.valueOf(100.0)) + ticket.applySellerOfferPrice("Seller A", BigDecimal(6000)) } } @Test fun `양도 가격은 정가를 초과 할 수 없다`() { - // API + val ticket = Ticket( + id = 1L, + barcode = "ABCDEFGH", + sellerName = "Seller A", + expirationDateTime = LocalDateTime.now().plusDays(1), + originalPrice = BigDecimal.TEN, + ticketType = TicketType.MELON + ) + + assertThrows { + ticket.applySellerOfferPrice("Seller A", BigDecimal(20)) + } + } + @Test + fun `판매중인 티켓을 예약할 수 있다`() { + val ticket = Ticket( + id = 1L, + barcode = "ABCDEFGH", + sellerName = "Seller A", + expirationDateTime = LocalDateTime.now().plusHours(2), + originalPrice = BigDecimal.TEN, + ticketType = TicketType.MELON + ) + ticket.ticketReserve() + assertThat(ticket.ticketStatus).isEqualTo(TicketStatus.RESERVED) + } + @Test + fun `예약중인 티켓을 판매중으로 되돌릴 수 있다`() { + val ticket = Ticket( + id = 1L, + barcode = "ABCDEFGH", + sellerName = "Seller A", + expirationDateTime = LocalDateTime.now().plusHours(2), + originalPrice = BigDecimal.TEN, + ticketType = TicketType.MELON + ) + ticket.ticketReserve() + ticket.ticketOnSale() + assertThat(ticket.ticketStatus).isEqualTo(TicketStatus.ON_SALE) + } + @Test + fun `예약중인 티켓만 판매완료할 수 있다`() { + val ticket = Ticket( + id = 1L, + barcode = "ABCDEFGH", + sellerName = "Seller A", + expirationDateTime = LocalDateTime.now().plusHours(2), + originalPrice = BigDecimal.TEN, + ticketType = TicketType.MELON, + ) + ticket.ticketReserve() + ticket.ticketSold() + assertThat(ticket.ticketStatus).isEqualTo(TicketStatus.SOLD) + } + + @Test + fun `판매중 상태에서 바로 판매완료할 수 없다`() { + val ticket = Ticket( + id = 1L, + barcode = "ABCDEFGH", + sellerName = "Seller A", + expirationDateTime = LocalDateTime.now().plusHours(2), + originalPrice = BigDecimal.TEN, + ticketType = TicketType.MELON, + ) assertThrows { - Ticket(1L, LocalDateTime.now(), BigDecimal.TEN) + ticket.ticketSold() } } + + + } diff --git a/src/test/kotlin/org/example/user/domain/UserTest.kt b/src/test/kotlin/org/example/user/domain/UserTest.kt new file mode 100644 index 0000000..44fd784 --- /dev/null +++ b/src/test/kotlin/org/example/user/domain/UserTest.kt @@ -0,0 +1,62 @@ +package org.example.user.domain + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions.assertSoftly +import org.example.user.domain.enum.UserRole +import org.example.user.domain.model.User +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +class UserTest { + private fun createUser( + name: String = "테스트유저", + role: UserRole = UserRole.BUYER, + money: BigDecimal = BigDecimal(0), + ): User = User(name = name, role = role) + + @Test + fun `입금에 성공한다`() { + val user = createUser() + user.deposit(BigDecimal(10000)) + assertThat(user.money).isEqualTo(BigDecimal(10000)) + } + + @Test + fun `출금에 성공한다`() { + val user = createUser(money = BigDecimal(10000)) + user.withdraw(BigDecimal(3000)) + assertThat(user.money).isEqualTo(BigDecimal(7000)) + } + @Test + fun `0원을 입금할 수 없다`(){ + val user = createUser(money = BigDecimal(10000)) + assertThrows { + user.deposit(BigDecimal.ZERO) + } + } + @Test + fun `0원을 출금할 수 없다`(){ + val user = createUser() + assertThrows { + user.withdraw(BigDecimal.ZERO) + } + } + @Test + fun `잔액이 부족하면 입금할 수 없다`() { + val user = createUser() + assertThrows { + user.deposit(BigDecimal(5000)) + } + } + @Test + fun `출금액이 부족하면 출금할 수 없다`() { + val user = createUser() + assertSoftly { + assertThrows { + user.withdraw(BigDecimal(1000)) + } + } + } + +} \ No newline at end of file