From 93ccb666c4ae6c4506eba3c340cce7f3ed41b6e3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 15:11:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90=EC=B8=A0=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=9B=84=20languageCode=EA=B0=80?= =?UTF-8?q?=20null=EC=9D=B4=EB=A9=B4=20naver=20papago=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=20=EA=B0=90=EC=A7=80=20API=20=ED=98=B8=EC=B6=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioContentLanguageDetectEvent.kt | 121 ++++++++++++++++++ .../sodalive/content/AudioContentService.kt | 18 +++ src/main/resources/application.yml | 3 + 3 files changed, 142 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt new file mode 100644 index 0000000..191b835 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt @@ -0,0 +1,121 @@ +package kr.co.vividnext.sodalive.content + +import org.slf4j.LoggerFactory +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.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestTemplate + +/** + * 오디오 콘텐츠 메타데이터(제목/내용/태그)를 기반으로 파파고 언어 감지를 요청하기 위한 이벤트. + */ +class AudioContentLanguageDetectEvent( + val contentId: Long, + val query: String +) + +data class PapagoLanguageDetectResponse( + val langCode: String? +) + +@Component +class AudioContentLanguageDetectListener( + private val audioContentRepository: AudioContentRepository, + + @Value("\${cloud.naver.papago-client-id}") + private val papagoClientId: String, + + @Value("\${cloud.naver.papago-client-secret}") + private val papagoClientSecret: String +) { + + private val log = LoggerFactory.getLogger(AudioContentLanguageDetectListener::class.java) + + private val restTemplate: RestTemplate = RestTemplate() + + private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect" + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun detectLanguage(event: AudioContentLanguageDetectEvent) { + if (event.query.isBlank()) { + log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. contentId={}", event.contentId) + return + } + + val audioContent = audioContentRepository.findById(event.contentId).orElse(null) + if (audioContent == null) { + log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", event.contentId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!audioContent.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}", + event.contentId, + audioContent.languageCode + ) + return + } + + try { + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_FORM_URLENCODED + set("X-NCP-APIGW-API-KEY-ID", papagoClientId) + set("X-NCP-APIGW-API-KEY", papagoClientSecret) + } + + val body = LinkedMultiValueMap().apply { + // 파파고 스펙에 따라 query 파라미터에 제목/내용/태그를 공백으로 구분하여 전달 + add("query", event.query) + } + + val requestEntity = HttpEntity(body, headers) + + val response = restTemplate.postForEntity( + papagoDetectUrl, + requestEntity, + PapagoLanguageDetectResponse::class.java + ) + + if (!response.statusCode.is2xxSuccessful) { + log.warn( + "[PapagoLanguageDetect] Non-success status from Papago. status={}, contentId={}", + response.statusCode, + event.contentId + ) + return + } + + val langCode = response.body?.langCode?.takeIf { it.isNotBlank() } + if (langCode == null) { + log.warn("[PapagoLanguageDetect] langCode is null or blank in Papago response. contentId={}", event.contentId) + return + } + + audioContent.languageCode = langCode + + // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. + audioContentRepository.save(audioContent) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", + event.contentId, + langCode + ) + } catch (ex: Exception) { + // 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다. + log.error("[PapagoLanguageDetect] Failed to detect language via Papago. contentId={}", event.contentId, ex) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 15ed7bc..8a9fd94 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -332,6 +332,24 @@ class AudioContentService( audioContent.content = contentPath + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (audioContent.languageCode.isNullOrBlank()) { + val papagoQuery = listOf( + request.title.trim(), + request.detail.trim(), + request.tags.trim() + ) + .filter { it.isNotBlank() } + .joinToString(" ") + + applicationEventPublisher.publishEvent( + AudioContentLanguageDetectEvent( + contentId = audioContent.id!!, + query = papagoQuery + ) + ) + } + return CreateAudioContentResponse(contentId = audioContent.id!!) } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c268ead..aa3ad69 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,6 +45,9 @@ google: webClientId: ${GOOGLE_WEB_CLIENT_ID} cloud: + naver: + papagoClientId: ${NCLOUD_PAPAGO_CLIENT_ID} + papagoClientSecret: ${NCLOUD_PAPAGO_CLIENT_SECRET} aws: credentials: accessKey: ${APP_AWS_ACCESS_KEY}