-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
326 additions
and
0 deletions.
There are no files selected for viewing
81 changes: 81 additions & 0 deletions
81
backend/src/main/java/com/nbe2_3_3_team4/backend/controller/TossPaymentController.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ApiResponse<Any>> { | ||
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<ApiResponse<Any>> { | ||
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<ApiResponse<String>> { | ||
val orderId = tossPaymentService.confirmPayment(requestDto) | ||
return ResponseEntity.ok().body(ApiResponse.createSuccess(orderId)) | ||
} | ||
} |
9 changes: 9 additions & 0 deletions
9
...main/java/com/nbe2_3_3_team4/backend/domain/parking/repository/ParkingStatusRepository.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ParkingStatus?, Long?> { | ||
} |
14 changes: 14 additions & 0 deletions
14
...end/src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/dto/TossPaymentRequest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) | ||
} |
222 changes: 222 additions & 0 deletions
222
...src/main/java/com/nbe2_3_3_team4/backend/domain/tosspayment/service/TossPaymentService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String> { | ||
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 = "결제 성공 처리 중 오류 발생" | ||
} | ||
} |