diff --git a/src/main/kotlin/model/StockModels.kt b/src/main/kotlin/model/StockModels.kt index e089e90..a04894d 100644 --- a/src/main/kotlin/model/StockModels.kt +++ b/src/main/kotlin/model/StockModels.kt @@ -130,7 +130,7 @@ data class UnfilledOrder( val prdt_name: String, val ord_unpr: String, // JSON이 문자열이므로 String 권장 val ord_qty : String, - + val sll_buy_dvsn_cd: String, @SerialName("psbl_qty") val rmnd_qty: String, // JSON의 psbl_qty를 rmnd_qty로 매핑 val ord_dvsn_name: String, @@ -152,7 +152,7 @@ fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem { orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0, quantity = this.ord_qty.toIntOrNull() ?: 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리 remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0, - status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태 + status = if (this.sll_buy_dvsn_cd.equals("01")) "SELLING" else "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태 isDomestic = isDomestic ) } @@ -163,4 +163,12 @@ data class StockBasicInfo( val name: String, val isDomestic: Boolean, val quantity: String = "0" +) + +data class ExecutionData( + val orderNo: String, + val code: String, + val price: String, + val qty: String, + val isFilled: Boolean ) \ No newline at end of file diff --git a/src/main/kotlin/network/KisAuthService.kt b/src/main/kotlin/network/KisAuthService.kt index 935f284..3baa647 100644 --- a/src/main/kotlin/network/KisAuthService.kt +++ b/src/main/kotlin/network/KisAuthService.kt @@ -30,7 +30,7 @@ class KisAuthService { // [수정] 모든 로그(Headers + Body)를 찍도록 설정 install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경 + level = LogLevel.NONE // 상세한 디버깅을 위해 ALL로 변경 } } diff --git a/src/main/kotlin/network/KisTradeService.kt b/src/main/kotlin/network/KisTradeService.kt index bd62553..67a9cf5 100644 --- a/src/main/kotlin/network/KisTradeService.kt +++ b/src/main/kotlin/network/KisTradeService.kt @@ -41,7 +41,7 @@ class KisTradeService { // [수정] 모든 로그(Headers + Body)를 찍도록 설정 install(Logging) { logger = Logger.DEFAULT - level = LogLevel.ALL // 상세한 디버깅을 위해 ALL로 변경 + level = LogLevel.BODY } } @@ -337,18 +337,7 @@ class KisTradeService { } catch (e: Exception) { Result.failure(e) } } - fun UnfilledOrder.toAutoTradeItem(isDomestic: Boolean): AutoTradeItem { - return AutoTradeItem( - orderNo = this.ord_no, - code = this.pdno, - name = this.prdt_name, - orderedPrice = this.ord_unpr.toDoubleOrNull() ?: 0.0, - quantity = 0, // 미체결 내역에서는 원 주문 수량을 알기 어려우므로 0 또는 별도 처리 - remainedQuantity = this.rmnd_qty.toIntOrNull() ?: 0, - status = "PENDING_BUY", // 기본적으로 미체결은 매수/매도 대기 상태 - isDomestic = isDomestic - ) - } + /** * [추가] 국내 미체결 내역 조회 @@ -381,14 +370,9 @@ class KisTradeService { parameter("INQR_DVSN_1", "0") parameter("INQR_DVSN_2", "0") } - println("result >> ${response.status}") val body = response.body() - println("result >> ${body.msg1}") - println("result >> ${body.rt_cd}") if (body.rt_cd == "0") { - var result = body - println("result >> ${result.output.size}") Result.success(result.output) } else Result.failure(Exception(body.msg1)) diff --git a/src/main/kotlin/network/KisWebSocketManager.kt b/src/main/kotlin/network/KisWebSocketManager.kt index 0fd7eed..17a88b9 100644 --- a/src/main/kotlin/network/KisWebSocketManager.kt +++ b/src/main/kotlin/network/KisWebSocketManager.kt @@ -6,11 +6,19 @@ import io.ktor.client.plugins.websocket.* import io.ktor.http.* import io.ktor.websocket.* import kotlinx.coroutines.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import model.KisSession +import util.AesCrypto import java.util.concurrent.atomic.AtomicBoolean class KisWebSocketManager { - private val client = HttpClient { install(WebSockets) } + private val client = HttpClient { + install(WebSockets) { + pingInterval = 20_000 // 20초마다 표준 웹소켓 핑 전송 (서버-클라이언트 연결 유지 도움) + } + } private var session: DefaultClientWebSocketSession? = null private val isConnected = AtomicBoolean(false) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -21,27 +29,42 @@ class KisWebSocketManager { suspend fun connect() { if (isConnected.get()) return - val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:21000" else "ops.koreainvestment.com:31000" + val url = if (KisSession.config.isSimulation) "ops.koreainvestment.com:31000" else "ops.koreainvestment.com:21000" scope.launch { - try { - client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") { - session = this - isConnected.set(true) - println("✅ 웹소켓 연결 성공") + while (isActive) { // 재연결을 위한 루프 추가 + try { + client.webSocket(method = HttpMethod.Get, host = url.split(":")[0], port = url.split(":")[1].toInt(), path = "/last_price") { + session = this + isConnected.set(true) + println("✅ 웹소켓 연결 성공") - // 연결 직후 HTS ID 기반 체결 통보 자동 구독 - val htsId = KisSession.config.htsId - if (htsId.isNotEmpty()) sendRequest("1", "H0STT084R", htsId) + // 기존 구독 신청 로직 (H0STCNI0 등) + val htsId = KisSession.config.htsId + if (htsId.isNotEmpty()) sendRequest("1", "H0STCNI0", htsId) - for (frame in incoming) { - if (frame is Frame.Text) handleMessage(frame.readText()) + // 메시지 수신 루프 + for (frame in incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + // [핵심] PINGPONG 처리: 수신된 텍스트 그대로 다시 전송 + if (text.contains("PINGPONG")) { + send(Frame.Text(text)) + // println("🔄 PINGPONG 응답 완료") + } else { + handleMessage(text) + } + } + } } + } catch (e: Exception) { + println("❌ 웹소켓 연결 끊김: ${e.message}") + } finally { + isConnected.set(false) + session = null + println("⏳ 5초 후 재연결 시도...") + delay(5000) // 5초 후 재연결 시도 } - } catch (e: Exception) { - println("❌ 웹소켓 에러: ${e.message}") - } finally { - isConnected.set(false) } } } @@ -56,38 +79,76 @@ class KisWebSocketManager { subscribeStock(code, isSubscribe = false) } + + // 체결 통보 복호화를 위한 키 저장소 + private var aesKey: String = "" + private var aesIv: String = "" + private fun handleMessage(message: String) { + if (message.startsWith("{")) { + val json = Json.parseToJsonElement(message).jsonObject + val trId = json["header"]?.jsonObject?.get("tr_id")?.jsonPrimitive?.content + + if (trId == "H0STCNI0" || trId == "H0STCNI9") { + val output = json["body"]?.jsonObject?.get("output")?.jsonObject + aesKey = output?.get("key")?.jsonPrimitive?.content ?: "" + aesIv = output?.get("iv")?.jsonPrimitive?.content ?: "" + println("🔑 복호화 키 획득 완료: KEY[$aesKey]") + } + return + } + if (!message.startsWith("0") && !message.startsWith("1")) return + // 2. 실시간 데이터 처리 val parts = message.split("|") if (parts.size < 4) return + + val leadingChar = message[0] // '0' 또는 '1' val trId = parts[1] - val dataRows = parts[3].split("^") - when (trId) { - "H0STCNT0" -> { - val price = dataRows[2] - _currentPrice.value = price // 상태 업데이트 - onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0) + when (leadingChar) { + '0' -> { // 일반 시세 (암호화 안됨) + if (trId == "H0STCNT0") { + val dataRows = parts[3].split("^") + val price = dataRows[2] + _currentPrice.value = price // 상태 업데이트 + onPriceUpdate?.invoke(dataRows[0], price.toDoubleOrNull() ?: 0.0) - // 로그 추가 (예시) - tradeLogs.add(0, model.RealTimeTrade( - time = dataRows[1], - price = price, - change = dataRows[4], - volume = dataRows[2], - type = model.TradeType.NEUTRAL - )) - if (tradeLogs.size > 50) tradeLogs.removeLast() + // 로그 추가 (예시) + tradeLogs.add( + 0, model.RealTimeTrade( + time = dataRows[1], + price = price, + change = dataRows[4], + volume = dataRows[2], + type = model.TradeType.NEUTRAL + ) + ) + if (tradeLogs.size > 50) tradeLogs.removeLast() + } } - "H0STT084R" -> { - println("채결 데이터") - onExecutionReceived?.invoke(dataRows[5], dataRows[9], dataRows[12], dataRows[13], dataRows[15] == "02") - } - else -> { - println("쓰레기? ${trId}") + + '1' -> { // 체결 통보 (암호화 됨) + if ((trId == "H0STCNI0" || trId == "H0STCNI9") && aesKey.isNotEmpty()) { + // AES 복호화 실행 + val decryptedData = AesCrypto.decrypt(parts[3], aesKey, aesIv) + val dataRows = decryptedData.split("^") + + println("🔔 복호화된 체결 통보: ${dataRows[8]} ${dataRows[9]}주 ${dataRows[13]} 체결") + + // UI 콜백 호출 (종목코드, 체결량, 체결가, 주문번호, 체결여부) + onExecutionReceived?.invoke( + dataRows[8], // 주식단축종목코드 + dataRows[9], // 체결수량 + dataRows[10], // 체결단가 + dataRows[2], // 주문번호 + dataRows[13] == "2" // 체결여부 (02: 체결) + ) + } } } } + fun clearData() { tradeLogs.clear() _currentPrice.value = "0" diff --git a/src/main/kotlin/ui/ActiveTradeRow.kt b/src/main/kotlin/ui/ActiveTradeRow.kt index de58b7e..31252bb 100644 --- a/src/main/kotlin/ui/ActiveTradeRow.kt +++ b/src/main/kotlin/ui/ActiveTradeRow.kt @@ -72,7 +72,7 @@ fun ActiveTradeRow( val detailText = when (item.status) { "PENDING_BUY" -> "설정 비율: 익절 ${item.profitRate}% / 손절 ${item.stopLossRate}%" "MONITORING" -> "목표가: ${String.format("%,.0f", item.targetPrice)} / 손절가: ${String.format("%,.0f", item.stopLossPrice)}" - else -> "주문번호: ${item.orderNo}" + else -> "주문번호: ${item.orderNo} ${item.orderedPrice} ${item.quantity}" } Text( diff --git a/src/main/kotlin/ui/DashboardScreen.kt b/src/main/kotlin/ui/DashboardScreen.kt index dd5b21e..9c7e059 100644 --- a/src/main/kotlin/ui/DashboardScreen.kt +++ b/src/main/kotlin/ui/DashboardScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import model.ExecutionData import model.KisSession import model.StockBasicInfo import network.KisTradeService @@ -22,7 +23,6 @@ fun DashboardScreen() { val tradeService = remember { KisTradeService() } val wsManager = remember { KisWebSocketManager() } val scope = rememberCoroutineScope() - var refreshTrigger by remember { mutableStateOf(0) } var selectedStockCode by remember { mutableStateOf("") } var selectedStockName by remember { mutableStateOf("") } var isDomestic by remember { mutableStateOf(true) } @@ -31,7 +31,53 @@ fun DashboardScreen() { var selectedItem by remember { mutableStateOf(null) } // 감시/미체결 아이템 선택 시 var selectedStockInfo by remember { mutableStateOf(null) } // 단순 종목 선택 시 +// 중앙 관리용 상태들 + var refreshTrigger by remember { mutableStateOf(0) } + // [핵심] 아직 DB에 등록되기 전에 도착한 체결 데이터를 임시 보관하는 버퍼 + val executionCache = remember { mutableMapOf() } + val processingIds = remember { mutableSetOf() } // 주문번호 기준 잠금 + // [중앙 관리 함수] 체결 정보와 DB 정보를 매칭하여 실행 + suspend fun syncAndExecute(orderNo: String) { + // 이미 다른 코루틴에서 이 주문을 처리 중이라면 즉시 종료 (중복 방지) + if (processingIds.contains(orderNo)) return + processingIds.add(orderNo) + try { + // DB 아이템과 체결 데이터(캐시)를 모두 가져옴 + val dbItem = DatabaseFactory.findByOrderNo(orderNo) + val execData = executionCache[orderNo] + + // 둘 다 존재할 때만 로직 실행 + if (dbItem != null && execData != null && execData.isFilled) { + if (dbItem.status == TradeStatus.PENDING_BUY) { + println("🎯 [매칭 성공] 익절 주문 실행: ${dbItem.name} (${orderNo})") + + val sellPrice = dbItem.targetPrice.toLong().toString() + tradeService.postOrder( + stockCode = dbItem.code, + qty = dbItem.quantity.toString(), + price = sellPrice, + isBuy = false + ).onSuccess { newSellOrderNo -> + DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.SELLING, newSellOrderNo) + // 처리가 완료된 체결 데이터는 캐시에서 삭제 + executionCache.remove(orderNo) + refreshTrigger++ + }.onFailure { + println("❌ 익절 주문 실패: ${it.message}") + } + } else if (dbItem.status == TradeStatus.SELLING) { + println("🎊 [매칭 성공] 매도 완료 처리: ${dbItem.name}") + DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) + executionCache.remove(orderNo) + refreshTrigger++ + } + } + } finally { + // 처리가 끝나면(성공/실패/매칭실패 모두) 잠금 해제 + processingIds.remove(orderNo) + } + } LaunchedEffect(Unit) { // 1. 웹소켓 연결 @@ -55,39 +101,12 @@ fun DashboardScreen() { } // 3. 실시간 체결 통보 핸들러 (주문번호 중심) - wsManager.onExecutionReceived = { orderNo, code, price, qty, isBuy -> + wsManager.onExecutionReceived = {code, qty, price,orderNo, isBuy -> scope.launch { - val dbItem = DatabaseFactory.findByOrderNo(orderNo) - if (dbItem != null) { - when (dbItem.status) { - TradeStatus.PENDING_BUY -> { - // 1. 매수 주문 체결 확인됨 -> 즉시 익절 매도 주문 발주 - println("✅ 매수 체결 확인 [${dbItem.name}]: 익절가 ${dbItem.targetPrice}로 매도 주문을 생성합니다.") - tradeService.postOrder( - stockCode = dbItem.code, - qty = dbItem.quantity.toString(), - price = dbItem.targetPrice.toLong().toString(), // 가격은 정수형 문자열로 전달 - isBuy = false - ).onSuccess { newSellOrderNo -> - // 2. 매도 주문 성공 시 DB 상태를 SELLING으로 변경하고 새로운 주문번호로 갱신 - DatabaseFactory.updateStatusAndOrderNo( - id = dbItem.id!!, - newStatus = TradeStatus.SELLING, - newOrderNo = newSellOrderNo - ) - println("🚀 익절 매도 주문 완료: 주문번호 $newSellOrderNo") - refreshTrigger++ // UI 갱신 - }.onFailure { - println("❌ 매수 체결 후 익절 주문 발주 실패: ${it.message}") - } - } - TradeStatus.SELLING -> { - // 매도(손절/익절) 주문 체결 -> COMPLETED - DatabaseFactory.updateStatusAndOrderNo(dbItem.id!!, TradeStatus.COMPLETED) - } - } - refreshTrigger++ - } + println("$orderNo, $code, $price, $qty, $isBuy") + val exec = ExecutionData(orderNo, code, price, qty, isBuy) + executionCache[orderNo] = exec + syncAndExecute(orderNo) } } } @@ -117,7 +136,12 @@ fun DashboardScreen() { holdingQuantity = selectedStockQuantity, isDomestic = isDomestic, tradeService = tradeService, - wsManager = wsManager + wsManager = wsManager, + onOrderSaved = { orderNo -> + scope.launch { + syncAndExecute(orderNo) // 매칭 시도 + } + } ) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/src/main/kotlin/ui/IntegratedOrderSection.kt b/src/main/kotlin/ui/IntegratedOrderSection.kt index 7954c19..06561ab 100644 --- a/src/main/kotlin/ui/IntegratedOrderSection.kt +++ b/src/main/kotlin/ui/IntegratedOrderSection.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import network.KisTradeService +import network.KisWebSocketManager +import util.MarketUtil /** * 통합 주문 및 자동매매 설정 섹션 @@ -31,6 +33,7 @@ fun IntegratedOrderSection( currentPrice: String, holdingQuantity: String, tradeService: KisTradeService, + onOrderSaved: (String) -> Unit, onOrderResult: (String, Boolean) -> Unit ) { val scope = rememberCoroutineScope() @@ -40,7 +43,16 @@ fun IntegratedOrderSection( mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) } - val isAutoSellEnabled = monitoringItem != null + var activeMonitoringItem by remember(stockCode) { + mutableStateOf(DatabaseFactory.findConfigByCode(stockCode)) + } + + // 2. 체크박스의 '의도' 상태 (신규 매수 시 자동감시를 켤 것인지 여부) + // 감시 중인 아이템이 있으면 true, 없으면 사용자 선택에 따름 + var willEnableAutoSell by remember(stockCode) { + mutableStateOf(activeMonitoringItem != null) + } + // UI 입력 상태 var orderPrice by remember { mutableStateOf("") } // 빈 값이면 시장가 @@ -94,8 +106,9 @@ fun IntegratedOrderSection( Column(modifier = Modifier.padding(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( - checked = isAutoSellEnabled, + checked = willEnableAutoSell, onCheckedChange = { checked -> + willEnableAutoSell = checked if (!checked) { // [감시 해제] DB ID를 사용하여 정확한 항목 삭제 (데이터 꼬임 방지) monitoringItem?.id?.let { dbId -> @@ -105,27 +118,27 @@ fun IntegratedOrderSection( } } else { // [즉시 감시 등록] 보유 종목에 대해 가상의 주문번호로 감시 시작 - if (curPriceNum > 0) { - val pRate = profitRate.toDoubleOrNull() ?: 0.0 - val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 - val target = curPriceNum * (1 + pRate / 100.0) - val stop = curPriceNum * (1 + sRate / 100.0) - - val newItem = AutoTradeItem( - orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}", - code = stockCode, - name = stockName, - quantity = inputQty, - profitRate = pRate, - stopLossRate = sRate, - targetPrice = target, - stopLossPrice = stop, - status = "MONITORING", - isDomestic = isDomestic - ) - DatabaseFactory.saveAutoTrade(newItem) - monitoringItem = DatabaseFactory.findConfigByCode(stockCode) - } +// if (curPriceNum > 0) { +// val pRate = profitRate.toDoubleOrNull() ?: 0.0 +// val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 +// val target = curPriceNum * (1 + pRate / 100.0) +// val stop = curPriceNum * (1 + sRate / 100.0) +// +// val newItem = AutoTradeItem( +// orderNo = "EXISTING_${stockCode}_${System.currentTimeMillis()}", +// code = stockCode, +// name = stockName, +// quantity = inputQty, +// profitRate = pRate, +// stopLossRate = sRate, +// targetPrice = target, +// stopLossPrice = stop, +// status = "MONITORING", +// isDomestic = isDomestic +// ) +// DatabaseFactory.saveAutoTrade(newItem) +// monitoringItem = DatabaseFactory.findConfigByCode(stockCode) +// } } } ) @@ -136,12 +149,12 @@ fun IntegratedOrderSection( OutlinedTextField( value = profitRate, onValueChange = { profitRate = it }, label = { Text("익절 %") }, modifier = Modifier.weight(1f).padding(end = 4.dp), - enabled = !isAutoSellEnabled + enabled = !willEnableAutoSell ) OutlinedTextField( value = stopLossRate, onValueChange = { stopLossRate = it }, label = { Text("손절 %") }, modifier = Modifier.weight(1f), - enabled = !isAutoSellEnabled + enabled = !willEnableAutoSell ) } } @@ -159,9 +172,12 @@ fun IntegratedOrderSection( tradeService.postOrder(stockCode, orderQty, finalPrice, isBuy = true) .onSuccess { realOrderNo -> // KIS 서버에서 생성된 실제 주문번호 onOrderResult("주문 성공: $realOrderNo", true) - if (isAutoSellEnabled) { + if (willEnableAutoSell) { val pRate = profitRate.toDoubleOrNull() ?: 0.0 val sRate = stopLossRate.toDoubleOrNull() ?: 0.0 + val calculatedTarget = MarketUtil.roundToTickSize(basePrice * (1 + pRate / 100.0)) + val calculatedStop = MarketUtil.roundToTickSize(basePrice * (1 + sRate / 100.0)) + DatabaseFactory.saveAutoTrade(AutoTradeItem( orderNo = realOrderNo, // 실제 주문번호 저장 (중심 관리 원칙) code = stockCode, @@ -169,12 +185,14 @@ fun IntegratedOrderSection( quantity = inputQty, profitRate = pRate, stopLossRate = sRate, - targetPrice = basePrice * (1 + pRate / 100.0), - stopLossPrice = basePrice * (1 + sRate / 100.0), + targetPrice = calculatedTarget, + stopLossPrice = calculatedStop, status = "PENDING_BUY", // 체결 전까지 PENDING_BUY 상태 isDomestic = isDomestic )) monitoringItem = DatabaseFactory.findConfigByCode(stockCode) + onOrderSaved(realOrderNo) + onOrderResult("매수 및 즉시 체결 확인: $realOrderNo", true) } } .onFailure { onOrderResult(it.message ?: "매수 실패", false) } diff --git a/src/main/kotlin/ui/StockDetailArea.kt b/src/main/kotlin/ui/StockDetailArea.kt index 9db5b04..3730e11 100644 --- a/src/main/kotlin/ui/StockDetailArea.kt +++ b/src/main/kotlin/ui/StockDetailArea.kt @@ -41,7 +41,8 @@ fun StockDetailSection( holdingQuantity : String, isDomestic: Boolean, tradeService: KisTradeService, - wsManager: KisWebSocketManager + wsManager: KisWebSocketManager, + onOrderSaved: (String) -> Unit ) { var openPrice by remember { mutableStateOf("0") } @@ -220,6 +221,7 @@ fun StockDetailSection( currentPrice = wsManager.currentPrice.value, holdingQuantity = holdingQuantity, tradeService = tradeService, + onOrderSaved = onOrderSaved, onOrderResult = { msg, success -> resultMessage = msg isSuccess = success diff --git a/src/main/kotlin/util/AesCrypto.kt b/src/main/kotlin/util/AesCrypto.kt new file mode 100644 index 0000000..7c91456 --- /dev/null +++ b/src/main/kotlin/util/AesCrypto.kt @@ -0,0 +1,26 @@ +// src/main/kotlin/util/AesCrypto.kt +package util + +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object AesCrypto { + fun decrypt(cipherText: String, key: String, iv: String): String { + return try { + val keySpec = SecretKeySpec(key.toByteArray(Charsets.UTF_8), "AES") + val ivSpec = IvParameterSpec(iv.toByteArray(Charsets.UTF_8)) + + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec) + + val decodedBytes = Base64.getDecoder().decode(cipherText) + val decryptedBytes = cipher.doFinal(decodedBytes) + + String(decryptedBytes, Charsets.UTF_8) + } catch (e: Exception) { + "복호화 실패: ${e.message}" + } + } +} \ No newline at end of file