From 304c001a2743adf5c00f740953fb1cc31c77de80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 16:34:22 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=8C=ED=8C=8C=EA=B3=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/PapagoTranslationResponse.kt | 40 ++++++ .../translation/PapagoTranslationService.kt | 127 ++++++++++++++++++ .../i18n/translation/TranslateRequest.kt | 11 ++ 3 files changed, 178 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslateRequest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationResponse.kt new file mode 100644 index 0000000..d0e0e29 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationResponse.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.i18n.translation + +/** + * Papago 번역 API 응답 예시 + * + * ```json + * { + * "message": { + * "result": { + * "srcLangType": "ko", + * "tarLangType": "en", + * "translatedText": "Hello, I like to eat apple while riding a bicycle." + * } + * } + * } + * ``` + */ + +/** + * 위 JSON 구조에 대응하는 최상위 응답 모델 + */ +data class PapagoTranslationResponse( + val message: Message +) { + /** + * message 필드 내부 구조 + */ + data class Message( + val result: Result + ) + + /** + * 실제 번역 결과 데이터 + */ + data class Result( + val srcLangType: String, + val tarLangType: String, + val translatedText: String + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt new file mode 100644 index 0000000..14197db --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -0,0 +1,127 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.web.client.RestTemplate + +@Service +class PapagoTranslationService( + @Value("\${cloud.naver.papago-client-id}") + private val papagoClientId: String, + + @Value("\${cloud.naver.papago-client-secret}") + private val papagoClientSecret: String +) { + private val restTemplate: RestTemplate = RestTemplate() + + private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation" + + fun translate(request: TranslateRequest): TranslateResult { + if (request.texts.isEmpty()) { + return TranslateResult(emptyList()) + } + + validateLanguages(request.sourceLanguage, request.targetLanguage) + + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set("X-NCP-APIGW-API-KEY-ID", papagoClientId) + set("X-NCP-APIGW-API-KEY", papagoClientSecret) + } + + val chunks = chunkTexts(request.texts) + val translatedTexts = mutableListOf() + + chunks.forEach { chunk -> + val textsInChunkCount = chunk.split(S3P_DELIMITER).size + + try { + val body = mapOf( + "source" to request.sourceLanguage, + "target" to request.targetLanguage, + "text" to chunk + ) + + val requestEntity = HttpEntity(body, headers) + + val response = restTemplate.postForEntity( + papagoTranslateUrl, + requestEntity, + PapagoTranslationResponse::class.java + ) + + if (!response.statusCode.is2xxSuccessful) { + return@forEach + } + + val translated = response.body?.message?.result?.translatedText + if (translated.isNullOrBlank()) { + repeat(textsInChunkCount) { translatedTexts.add("") } + } else { + translated.split(S3P_DELIMITER).forEach { translatedTexts.add(it) } + } + } catch (_: Exception) { + repeat(textsInChunkCount) { translatedTexts.add("") } + } + } + + return TranslateResult(translatedTexts) + } + + private fun validateLanguages(sourceLanguage: String, targetLanguage: String) { + requireSupportedLanguage(sourceLanguage) + requireSupportedLanguage(targetLanguage) + } + + private fun requireSupportedLanguage(language: String) { + val normalized = language.lowercase() + if (!SUPPORTED_LANGUAGE_CODES.contains(normalized)) { + throw IllegalArgumentException("지원하지 않는 언어 코드입니다: $language") + } + } + + private fun chunkTexts(texts: List): List { + if (texts.isEmpty()) return emptyList() + val chunks = mutableListOf() + var startIndex = 0 + while (startIndex < texts.size) { + var endIndex = texts.size + while (endIndex > startIndex) { + val candidate = texts.subList(startIndex, endIndex) + val joined = candidate.joinToString(S3P_DELIMITER) + if (joined.length <= MAX_TEXT_LENGTH || endIndex - startIndex == 1) { + chunks.add(joined) + startIndex = endIndex + break + } + endIndex-- + } + } + return chunks + } + + companion object { + private val SUPPORTED_LANGUAGE_CODES = setOf( + "ko", + "en", + "ja", + "zh-cn", + "zh-tw", + "es", + "fr", + "vi", + "th", + "id", + "de", + "ru", + "pt", + "it" + ) + + private const val S3P_DELIMITER = "__S3P__" + private const val MAX_TEXT_LENGTH = 3000 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslateRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslateRequest.kt new file mode 100644 index 0000000..ff4fed6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslateRequest.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.i18n.translation + +data class TranslateRequest( + val texts: List, + val sourceLanguage: String, + val targetLanguage: String +) + +data class TranslateResult( + val translatedText: List +)