diff --git a/backend/src/main/java/com/nbe2_3_3_team4/backend/controller/TossPaymentController.kt b/backend/src/main/java/com/nbe2_3_3_team4/backend/controller/TossPaymentController.kt new file mode 100644 index 0000000..8a9da6f --- /dev/null +++ b/backend/src/main/java/com/nbe2_3_3_team4/backend/controller/TossPaymentController.kt @@ -0,0 +1,81 @@ +package com.nbe2_3_3_team4.backend.controller + +import com.nbe2_3_3_team4.backend.domain.tosspayment.dto.TossPaymentRequest.PaymentConfirmation +import com.nbe2_3_3_team4.backend.domain.tosspayment.dto.TossPaymentRequest.TempAmountSession +import com.nbe2_3_3_team4.backend.domain.tosspayment.service.TossPaymentService +import com.nbe2_3_3_team4.backend.global.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpSession +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +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("/api/toss-payments") +@Tag(name = "๐Ÿ’ฒTossPayment", description = "ํ† ์Šค ๊ฒฐ์ œ ๊ด€๋ จ API") +class TossPaymentController(val tossPaymentService: TossPaymentService) { + + /** + * ๊ฒฐ์ œ๋ฅผ ์š”์ฒญํ•˜๊ธฐ ์ „์— orderNumber์™€ amount๋ฅผ ์„ธ์…˜์— ์ €์žฅํ•˜๋Š” controller (๊ฒฐ์ œ ์š”์ฒญ๊ณผ ์Šน์ธ ์‚ฌ์ด์— ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ํ™•์ธ) + * @param session HttpSession + * @param requestDto TosspaymentRequest.TempAmountSession + * @return null + */ + @Operation(summary = "๊ฒฐ์ œ ์ „ ์ฃผ๋ฌธ๊ธˆ์•ก ์„ธ์…˜์— ์ž„์‹œ ์ €์žฅ API", description = "์ฃผ๋ฌธ๊ธˆ์•ก์„ ์„ธ์…˜์— ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses( + value = [io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "201", + description = "์ž„์‹œ ์ €์žฅ ์„ฑ๊ณต" + )] + ) + @PostMapping("/amounts/session") + fun createTempPaymentAmount( + session: HttpSession, + @RequestBody requestDto: TempAmountSession + ): ResponseEntity> { + tossPaymentService.createTempPaymentAmount(session, requestDto) + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.createSuccessWithNoData()) + } + + /** + * ์„ธ์…˜์— ์ €์žฅํ•ด๋‘” amount์™€ ๊ฒฐ์ œ ํ›„ amount ๋น„๊ตํ•˜์—ฌ ๊ฒ€์ฆ controller + * @param session HttpSession + * @param requestDto TosspaymentRequest.TempAmountSession + * @return null + */ + @Operation(summary = "์„ธ์…˜์— ์ €์žฅ๋œ ๊ธˆ์•ก๊ณผ ์‹ค์ œ ๊ฒฐ์ œ ๊ธˆ์•ก ๊ฒ€์ฆ API") + @ApiResponses( + value = [io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "๊ฒ€์ฆ ์„ฑ๊ณต" + )] + ) + @PostMapping("/amounts/verify") + fun verifyPaymentAmount( + session: HttpSession, + @RequestBody requestDto: TempAmountSession + ): ResponseEntity> { + tossPaymentService.verifyPaymentAmountAndRemoveSession(session, requestDto) + return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoData()) + } + + + @Operation(summary = "ํ† ์ŠคํŽ˜์ด๋จผ์ธ ์— ๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ API") + @ApiResponses( + value = [io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "์Šน์ธ ์„ฑ๊ณต" + )] + ) + @PostMapping("/confirm") + fun confirmPayment(@RequestBody requestDto: PaymentConfirmation): ResponseEntity> { + val orderId = tossPaymentService.confirmPayment(requestDto) + return ResponseEntity.ok().body(ApiResponse.createSuccess(orderId)) + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/parking/repository/ParkingStatusRepository.kt b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/parking/repository/ParkingStatusRepository.kt new file mode 100644 index 0000000..f3ecf58 --- /dev/null +++ b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/parking/repository/ParkingStatusRepository.kt @@ -0,0 +1,9 @@ +package com.nbe2_3_3_team4.backend.domain.parking.repository + +import com.nbe2_3_3_team4.backend.domain.parking.entity.ParkingStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ParkingStatusRepository : JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/dto/TossPaymentRequest.kt b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/dto/TossPaymentRequest.kt new file mode 100644 index 0000000..476c4a5 --- /dev/null +++ b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/dto/TossPaymentRequest.kt @@ -0,0 +1,14 @@ +package com.nbe2_3_3_team4.backend.domain.tosspayment.dto + +class TossPaymentRequest { + class TempAmountSession( + val orderId: String, + val amount: String + ) + + class PaymentConfirmation( + val orderId: String, + val paymentKey: String, + val amount: String + ) +} \ No newline at end of file diff --git a/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/service/TossPaymentService.kt b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/service/TossPaymentService.kt new file mode 100644 index 0000000..e85faec --- /dev/null +++ b/backend/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/service/TossPaymentService.kt @@ -0,0 +1,222 @@ +package com.nbe2_3_3_team4.backend.domain.tosspayment.service + +import com.nbe2_3_3_team4.backend.domain.order.entity.Order +import com.nbe2_3_3_team4.backend.domain.order.entity.enums.PaymentStatus +import com.nbe2_3_3_team4.backend.domain.order.repository.OrderRepository +import com.nbe2_3_3_team4.backend.domain.parking.entity.ParkingStatus +import com.nbe2_3_3_team4.backend.domain.parking.repository.ParkingStatusRepository +import com.nbe2_3_3_team4.backend.domain.tosspayment.dto.TossPaymentRequest.PaymentConfirmation +import com.nbe2_3_3_team4.backend.domain.tosspayment.dto.TossPaymentRequest.TempAmountSession +import com.nbe2_3_3_team4.backend.global.exception.* +import jakarta.servlet.http.HttpSession +import org.json.JSONObject +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.io.IOException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.* + +@Service +class TossPaymentService( + val orderRepository: OrderRepository, + val parkingStatusRepository: ParkingStatusRepository, + @Value("\${TOSS_PAYMENT_SECRET_KEY}") + private val secretKey: String +) { + + private val logger: Logger = LoggerFactory.getLogger(TossPaymentService::class.java) + + /** + * ๊ฒฐ์ œ ์ „ ๊ธˆ์•ก ์„ธ์…˜์— ์ €์žฅ ๋ฉ”์„œ๋“œ + * + * @param session + * @param requestDto + */ + fun createTempPaymentAmount( + session: HttpSession, + requestDto: TempAmountSession + ) { + session.setAttribute(requestDto.orderId, requestDto.amount) + } + + /** + * ๊ฒฐ์ œ ์ธ์ฆ ํ›„ ๊ธˆ์•ก๊ณผ ๊ฒฐ์ œ ์ „ ๊ธˆ์•ก ๋น„๊ตํ•˜์—ฌ ๊ฒ€์ฆ, ์„ธ์…˜ ๋ฐ์ดํ„ฐ ์‚ญ์ œ ๋ฉ”์„œ๋“œ + * + * @param session + * @param requestDto + */ + fun verifyPaymentAmountAndRemoveSession(session: HttpSession, requestDto: TempAmountSession) { + val amount = session.getAttribute(requestDto.orderId) as String + verifyPaymentAmount(amount, requestDto.amount) + // ๊ฒ€์ฆ์— ์‚ฌ์šฉํ–ˆ๋˜ ์„ธ์…˜์€ ์‚ญ์ œ + session.removeAttribute(requestDto.orderId) + } + + private fun verifyPaymentAmount(expectedAmount: String, actualAmount: String) { + if (expectedAmount != actualAmount) { + throw BadRequestException(ErrorCode.INVALID_PAYMENT_AMOUNT) + } + } + + /** + * ๊ฒฐ์ œ ์ธ์ฆ ํ›„ ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ + * + * @param requestDto TosspaymentRequest.PaymentConfirmation + * @return Long + */ + fun confirmPayment(requestDto: PaymentConfirmation): String { + var order: Order? = null + var confirmPaymentResponse: JSONObject? = null + + // ๋ฏธ๋ฆฌ ์ƒ์„ฑํ•œ ๊ฒฐ์ œ๋Œ€๊ธฐ ์ƒํƒœ ์ฃผ๋ฌธ ์กฐํšŒ + order = orderRepository.findById(requestDto.orderId) + .orElse(null) ?: throw NotFoundException(ErrorCode.ORDER_NOT_FOUND) + + //ํ† ์Šค์— ๊ฒฐ์ œ์š”์ฒญ + confirmPaymentResponse = requestPaymentConfirmation(requestDto, order) + + // ๊ฒฐ์ œ ์„ฑ๊ณต ์ฒ˜๋ฆฌ + updateOrderPaymentAndStatus(order, confirmPaymentResponse) + return order.id + } + + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ  ๊ฒฐ์ œ ์Šน์ธ API ์š”์ฒญ + private fun requestPaymentConfirmation(requestDto: PaymentConfirmation, order: Order?): JSONObject { + try { + // ํ—ค๋” ์ธ์ฝ”๋”ฉ + val authorizationHeader = authorizationHeader + // API ์š”์ฒญ + val response = requestConnect(requestDto, authorizationHeader) + + val responseCode = response.statusCode() + logger.info(responseCode.toString() + " " + response.body()) + + val responseJson = JSONObject(response.body()) + + // ๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ ์‹คํŒจ + if (responseCode != 200) { + logger.error("๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ ์‹คํŒจ") + if (Objects.nonNull(order)) { + // ์ฃผ๋ฌธ ์‚ญ์ œ + deleteOrder(order!!) + // ์ž๋ฆฌ ๋ณต๊ตฌ + updateParkingStatusCnt(order.ticket.parking?.parkingStatus) + } + throw TossPaymentConfirmException(responseCode, (responseJson["message"] as String)) + } + logger.info(responseJson["orderName"].toString() + responseJson["totalAmount"].toString()) + return responseJson + } catch (e: IOException) { + logger.error("๊ฒฐ์ œ ์Šน์ธ ์š”์ฒญ ๊ฐ’ ์˜ค๋ฅ˜") + if (Objects.nonNull(order)) { + deleteOrder(order!!) + updateParkingStatusCnt(order.ticket.parking?.parkingStatus) + } + throw TossPaymentException(ErrorCode.TOSS_PAYMENT_CONFIRM_REQUEST_ERROR) + } catch (e: InterruptedException) { + logger.error("ํŒŒ์‹ฑ ์‹คํŒจ") + if (Objects.nonNull(order)) { + deleteOrder(order!!) + updateParkingStatusCnt(order.ticket.parking?.parkingStatus) + } + throw TossPaymentException(ErrorCode.INVALID_PAYMENT_RESPONSE_JSON) + } + } + + private fun handlePaymentCancellation(jsonObject: JSONObject) { + try { + val paymentKey = jsonObject["paymentKey"] as String + requestPaymentCancel(paymentKey, CANCEL_REASON) + throw BadRequestException(ErrorCode.INTERNAL_SERVER_ERROR) + } catch (e: Exception) { + throw TossPaymentException(ErrorCode.TOSS_PAYMENT_CANCEL_REQUEST_ERROR) + } + } + + @Throws(IOException::class, InterruptedException::class) + private fun requestConnect(requestDto: PaymentConfirmation, authorizationHeader: String): HttpResponse { + val request = HttpRequest.newBuilder() + .uri(URI.create("https://api.tosspayments.com/v1/payments/confirm")) + .header("Authorization", authorizationHeader) + .header("Content-Type", "application/json") + .method( + "POST", HttpRequest.BodyPublishers.ofString( + ("{\"paymentKey\":\"" + requestDto.paymentKey + + "\",\"orderId\":\"" + requestDto.orderId + + "\",\"amount\":\"" + requestDto.amount + + "\"}") + ) + ) + .build() + return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) + } + + @Throws(IOException::class, InterruptedException::class) + private fun requestPaymentCancel(paymentKey: String, cancelReason: String) { + val request = HttpRequest.newBuilder() + .uri(URI.create("https://api.tosspayments.com/v1/payments/$paymentKey/cancel")) + .header("Authorization", authorizationHeader) + .header("Content-Type", "application/json") + .method( + "POST", + HttpRequest.BodyPublishers.ofString("{\"cancelReason\":\"$cancelReason\"}") + ) + .build() + + val response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()) + + logger.info("๊ฒฐ์ œ ์ทจ์†Œ paymentKey: {}, response: {}", paymentKey, response.body()) + } + + private val authorizationHeader: String + // ํ† ์ŠคํŽ˜์ด๋จผ์ธ ์™€ ์—ฐ๋™ํ•˜๊ธฐ ์œ„ํ•œ ์ธ์ฆ Header ์„ค์ • + get() { + // "Basic" + ํ† ์ŠคํŽ˜์ด๋จผ์ธ  API ์‹œํฌ๋ฆฟ ํ‚ค + ":" -> base64์ธ์ฝ”๋”ฉ + val encoder = Base64.getEncoder() + val encodedBytes = encoder.encode(("$secretKey:").toByteArray(StandardCharsets.UTF_8)) + return "Basic " + String(encodedBytes) + } + + private fun updateOrderPaymentAndStatus(order: Order, jsonObject: JSONObject) { + try { + val paymentKey = jsonObject.getString("paymentKey") + val paymentDate = parseLocalDateTime(jsonObject["approvedAt"] as String) + + order.updatePaymentInfo(paymentKey, paymentDate) + order.updatePaymentStatus(PaymentStatus.COMPLETE) + orderRepository.save(order) + } catch (e: Exception) { + logger.error("๊ฒฐ์ œ ์„ฑ๊ณต ํ›„ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์˜ค๋ฅ˜") + handlePaymentCancellation(jsonObject) + deleteOrder(order) + updateParkingStatusCnt(order.ticket.parking?.parkingStatus) + throw BadRequestException(ErrorCode.INTERNAL_SERVER_ERROR) + } + } + + private fun parseLocalDateTime(dateTimeString: String): LocalDateTime { + return LocalDateTime.parse(dateTimeString, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } + + private fun deleteOrder(order: Order) { + orderRepository.delete(order) + } + + private fun updateParkingStatusCnt(parkingStatus: ParkingStatus?) { + parkingStatus?.decreaseUsedParkingSpace() + parkingStatusRepository.save(parkingStatus!!) + } + + companion object { + private const val CANCEL_REASON = "๊ฒฐ์ œ ์„ฑ๊ณต ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ" + } +} \ No newline at end of file