From 608898eb0c9467edb155ce00de93f9c358da1b9f Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 22:00:30 +0900 Subject: [PATCH] Add translation support for audio content detail --- .../content/AudioContentController.kt | 4 +- .../sodalive/content/AudioContentService.kt | 95 ++++++++++++++++++- .../content/GetAudioContentDetailResponse.kt | 4 +- .../content/translation/ContentTranslation.kt | 64 +++++++++++++ .../ContentTranslationRepository.kt | 7 ++ 5 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt index 5cddb91..fa1544b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -131,6 +131,7 @@ class AudioContentController(private val service: AudioContentService) { fun getDetail( @PathVariable id: Long, @RequestParam timezone: String, + @RequestParam(required = false, defaultValue = "ko") languageCode: String? = "ko", @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { @@ -141,7 +142,8 @@ class AudioContentController(private val service: AudioContentService) { id = id, member = member, isAdultContentVisible = isAdultContentVisible ?: true, - timezone = timezone + timezone = timezone, + languageCode = languageCode ?: "ko" ) ) } 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 4b64d8a..b86e159 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -21,10 +21,14 @@ import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.pin.PinContent import kr.co.vividnext.sodalive.content.pin.PinContentRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.content.translation.TranslatedContent import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.utils.generateFileName @@ -56,6 +60,9 @@ class AudioContentService( private val audioContentLikeRepository: AudioContentLikeRepository, private val pinContentRepository: PinContentRepository, + private val translationService: PapagoTranslationService, + private val contentTranslationRepository: ContentTranslationRepository, + private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, private val audioContentCloudFront: AudioContentCloudFront, @@ -500,7 +507,8 @@ class AudioContentService( id: Long, member: Member, isAdultContentVisible: Boolean, - timezone: String + timezone: String, + languageCode: String ): GetAudioContentDetailResponse { val isAdult = member.auth != null && isAdultContentVisible @@ -718,6 +726,88 @@ class AudioContentService( listOf() } + var translated: TranslatedContent? = null + + /** + * audioContent.languageCode != languageCode + * + * 번역 콘텐츠를 조회한다. - contentId, locale + * 번역 콘텐츠가 있으면 + * TranslatedContent로 가공한다 + * + * 번역 콘텐츠가 없으면 + * 파파고 API를 통해 번역한 후 저장한다. + * + * 번역 대상: title, detail, tags + * + * 파파고로 번역한 데이터를 TranslatedContent로 가공한다 + */ + if ( + audioContent.languageCode != null && + audioContent.languageCode!!.isNotBlank() && + languageCode.isNotBlank() && + audioContent.languageCode != languageCode + ) { + val locale = languageCode.lowercase() + + val existing = contentTranslationRepository + .findByContentIdAndLocale(audioContent.id!!, locale) + + if (existing != null) { + val payload = existing.renderedPayload + translated = TranslatedContent( + title = payload.title, + detail = payload.detail, + tags = payload.tags + ) + } else { + val texts = mutableListOf() + texts.add(audioContent.title) + texts.add(audioContent.detail) + texts.add(tag) + + val sourceLanguage = audioContent.languageCode ?: "ko" + + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = sourceLanguage, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + var index = 0 + + val translatedTitle = translatedTexts[index++] + val translatedDetail = translatedTexts[index++] + val translatedTags = translatedTexts[index] + + val payload = kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload( + title = translatedTitle, + detail = translatedDetail, + tags = translatedTags + ) + + contentTranslationRepository.save( + kr.co.vividnext.sodalive.content.translation.ContentTranslation( + contentId = audioContent.id!!, + locale = locale, + translatedTitle = translatedTitle, + renderedPayload = payload + ) + ) + + translated = TranslatedContent( + title = translatedTitle, + detail = translatedDetail, + tags = translatedTags + ) + } + } + } + return GetAudioContentDetailResponse( contentId = audioContent.id!!, title = audioContent.title, @@ -765,7 +855,8 @@ class AudioContentService( previousContent = previousContent, nextContent = nextContent, buyerList = buyerList, - isAvailableUsePoint = audioContent.isPointAvailable + isAvailableUsePoint = audioContent.isPointAvailable, + translated = translated ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt index 0511730..69f8329 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.content import com.querydsl.core.annotations.QueryProjection import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.translation.TranslatedContent data class GetAudioContentDetailResponse( val contentId: Long, @@ -40,7 +41,8 @@ data class GetAudioContentDetailResponse( val previousContent: OtherContentResponse?, val nextContent: OtherContentResponse?, val buyerList: List, - val isAvailableUsePoint: Boolean + val isAvailableUsePoint: Boolean, + val translated: TranslatedContent? ) data class OtherContentResponse @QueryProjection constructor( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt new file mode 100644 index 0000000..df4cd98 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.content.translation + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.AttributeConverter +import javax.persistence.Column +import javax.persistence.Convert +import javax.persistence.Converter +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["contentId", "locale"]) + ] +) +class ContentTranslation( + val contentId: Long, + val locale: String, + val translatedTitle: String, + + @Column(columnDefinition = "json") + @Convert(converter = ContentTranslationPayloadConverter::class) + val renderedPayload: ContentTranslationPayload +) : BaseEntity() + +data class ContentTranslationPayload( + val title: String, + val detail: String, + val tags: String +) + +@Converter(autoApply = false) +class ContentTranslationPayloadConverter : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: ContentTranslationPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): ContentTranslationPayload { + if (dbData.isNullOrBlank()) { + return ContentTranslationPayload( + title = "", + detail = "", + tags = "" + ) + } + return objectMapper.readValue(dbData) + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} + +data class TranslatedContent( + val title: String?, + val detail: String?, + val tags: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt new file mode 100644 index 0000000..e197d1e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface ContentTranslationRepository : JpaRepository { + fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation? +}