파파고 번역 API 연동
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<String>()
|
||||||
|
|
||||||
|
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<String>): List<String> {
|
||||||
|
if (texts.isEmpty()) return emptyList()
|
||||||
|
val chunks = mutableListOf<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
data class TranslateRequest(
|
||||||
|
val texts: List<String>,
|
||||||
|
val sourceLanguage: String,
|
||||||
|
val targetLanguage: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslateResult(
|
||||||
|
val translatedText: List<String>
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user