From edaea84a5b38c1e62f4a0b39a250d160d2c82138 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 24 Nov 2025 12:31:49 +0900 Subject: [PATCH 01/90] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=97=85=EB=A1=9C=EB=93=9C=20request,=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20response=EC=97=90=20languageCo?= =?UTF-8?q?de=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateAudioContentRequest, GetAudioContentDetailResponse --- .../kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt | 1 + .../kr/co/vividnext/sodalive/content/AudioContentService.kt | 2 ++ .../co/vividnext/sodalive/content/CreateAudioContentRequest.kt | 3 ++- .../sodalive/content/GetAudioContentDetailResponse.kt | 1 + 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt index e08a830c..a7cf23a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContent.kt @@ -32,6 +32,7 @@ data class AudioContent( var title: String, @Column(columnDefinition = "TEXT", nullable = false) var detail: String, + var languageCode: String?, var playCount: Long = 0, var price: Int = 0, var releaseDate: LocalDateTime? = null, 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 dfb4a0d2..15ed7bcf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -238,6 +238,7 @@ class AudioContentService( val audioContent = AudioContent( title = request.title.trim(), detail = request.detail.trim(), + languageCode = request.languageCode, price = if (request.price > 0) { request.price } else { @@ -703,6 +704,7 @@ class AudioContentService( contentId = audioContent.id!!, title = audioContent.title, detail = contentDetail, + languageCode = audioContent.languageCode, coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", contentUrl = audioContentUrl, themeStr = audioContent.theme!!.theme, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt index 2eacb00e..dae03f6e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/CreateAudioContentRequest.kt @@ -17,5 +17,6 @@ data class CreateAudioContentRequest( val isCommentAvailable: Boolean = false, val isFullDetailVisible: Boolean = true, val previewStartTime: String? = null, - val previewEndTime: String? = null + val previewEndTime: String? = null, + val languageCode: String? = null ) 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 9149ce28..05117302 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/GetAudioContentDetailResponse.kt @@ -8,6 +8,7 @@ data class GetAudioContentDetailResponse( val contentId: Long, val title: String, val detail: String, + val languageCode: String?, val coverImageUrl: String, val contentUrl: String, val themeStr: String, -- 2.49.1 From 93ccb666c4ae6c4506eba3c340cce7f3ed41b6e3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 15:11:27 +0900 Subject: [PATCH 02/90] =?UTF-8?q?feat(content):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=9B=84=20languageC?= =?UTF-8?q?ode=EA=B0=80=20null=EC=9D=B4=EB=A9=B4=20naver=20papago=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20API=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EA=B8=B0=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 00000000..191b835e --- /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 15ed7bcf..8a9fd942 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 c268eade..aa3ad691 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} -- 2.49.1 From ae2c69974813b81ea02f64f85d69bc02758170f9 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 15:42:32 +0900 Subject: [PATCH 03/90] =?UTF-8?q?refactor(LanguageDetectEvent):=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AudioContentLanguageDetectEvent -> LanguageDetectEvent --- .../co/vividnext/sodalive/content/AudioContentService.kt | 2 +- ...ntentLanguageDetectEvent.kt => LanguageDetectEvent.kt} | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/main/kotlin/kr/co/vividnext/sodalive/content/{AudioContentLanguageDetectEvent.kt => LanguageDetectEvent.kt} (94%) 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 8a9fd942..725dfe4d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -343,7 +343,7 @@ class AudioContentService( .joinToString(" ") applicationEventPublisher.publishEvent( - AudioContentLanguageDetectEvent( + LanguageDetectEvent( contentId = audioContent.id!!, query = papagoQuery ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt similarity index 94% rename from src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 191b835e..d34c2ee3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentLanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -17,7 +17,7 @@ import org.springframework.web.client.RestTemplate /** * 오디오 콘텐츠 메타데이터(제목/내용/태그)를 기반으로 파파고 언어 감지를 요청하기 위한 이벤트. */ -class AudioContentLanguageDetectEvent( +class LanguageDetectEvent( val contentId: Long, val query: String ) @@ -27,7 +27,7 @@ data class PapagoLanguageDetectResponse( ) @Component -class AudioContentLanguageDetectListener( +class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, @Value("\${cloud.naver.papago-client-id}") @@ -37,7 +37,7 @@ class AudioContentLanguageDetectListener( private val papagoClientSecret: String ) { - private val log = LoggerFactory.getLogger(AudioContentLanguageDetectListener::class.java) + private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java) private val restTemplate: RestTemplate = RestTemplate() @@ -46,7 +46,7 @@ class AudioContentLanguageDetectListener( @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) - fun detectLanguage(event: AudioContentLanguageDetectEvent) { + fun detectLanguage(event: LanguageDetectEvent) { if (event.query.isBlank()) { log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. contentId={}", event.contentId) return -- 2.49.1 From 5ee5107364173a84fee0a6e19beef322b13cf2a0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 15:54:01 +0900 Subject: [PATCH 04/90] =?UTF-8?q?feat(content-comment):=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=8C=93=EA=B8=80/=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=EC=8B=9C=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=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 --- .../sodalive/content/comment/AudioContentComment.kt | 1 + .../content/comment/AudioContentCommentController.kt | 3 ++- .../sodalive/content/comment/AudioContentCommentService.kt | 5 +++-- .../sodalive/content/comment/RegisterCommentRequest.kt | 3 ++- .../sodalive/content/donation/AudioContentDonationRequest.kt | 3 ++- .../sodalive/content/donation/AudioContentDonationService.kt | 1 + 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt index a84e468b..d3dfa459 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentComment.kt @@ -16,6 +16,7 @@ import javax.persistence.Table data class AudioContentComment( @Column(columnDefinition = "TEXT", nullable = false) var comment: String, + var languageCode: String?, @Column(nullable = true) var donationCan: Int? = null, val isSecret: Boolean = false, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index 879027df..ad91270e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -32,7 +32,8 @@ class AudioContentCommentController( audioContentId = request.contentId, parentId = request.parentId, isSecret = request.isSecret, - member = member + member = member, + languageCode = request.languageCode ) try { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 90b9be77..cb5b1967 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -32,7 +32,8 @@ class AudioContentCommentService( comment: String, audioContentId: Long, parentId: Long? = null, - isSecret: Boolean = false + isSecret: Boolean = false, + languageCode: String? ): Long { val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") @@ -50,7 +51,7 @@ class AudioContentCommentService( throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.") } - val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret) + val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret) audioContentComment.audioContent = audioContent audioContentComment.member = member diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt index a5fc8acc..8a09bf05 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/RegisterCommentRequest.kt @@ -4,5 +4,6 @@ data class RegisterCommentRequest( val comment: String, val contentId: Long, val parentId: Long?, - val isSecret: Boolean = false + val isSecret: Boolean = false, + val languageCode: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt index a8ed8a90..cff7b9a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationRequest.kt @@ -4,5 +4,6 @@ data class AudioContentDonationRequest( val contentId: Long, val donationCan: Int, val comment: String, - val container: String + val container: String, + val languageCode: String? = null ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt index d8e1e467..f92634de 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -34,6 +34,7 @@ class AudioContentDonationService( val audioContentComment = AudioContentComment( comment = request.comment, + languageCode = request.languageCode, donationCan = request.donationCan ) audioContentComment.audioContent = audioContent -- 2.49.1 From da9b89a6cf969984e44ac2c546170e40f63a1992 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:03:52 +0900 Subject: [PATCH 05/90] =?UTF-8?q?feat(content-comment):=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=8C=93=EA=B8=80/=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=EC=8B=9C=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?null=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=ED=8C=8C=ED=8C=8C?= =?UTF-8?q?=EA=B3=A0=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20API?= =?UTF-8?q?=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=98=EB=8A=94=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 --- .../sodalive/content/LanguageDetectEvent.kt | 123 ++++++++++++++---- .../comment/AudioContentCommentService.kt | 13 ++ .../donation/AudioContentDonationService.kt | 20 ++- 3 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index d34c2ee3..4321b00c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.content +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpEntity @@ -15,11 +16,18 @@ import org.springframework.util.LinkedMultiValueMap import org.springframework.web.client.RestTemplate /** - * 오디오 콘텐츠 메타데이터(제목/내용/태그)를 기반으로 파파고 언어 감지를 요청하기 위한 이벤트. + * 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트. */ +enum class LanguageDetectTargetType { + CONTENT, + COMMENT +} + class LanguageDetectEvent( - val contentId: Long, - val query: String + val contentId: Long? = null, + val query: String, + val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT, + val commentId: Long? = null ) data class PapagoLanguageDetectResponse( @@ -29,6 +37,7 @@ data class PapagoLanguageDetectResponse( @Component class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, + private val audioContentCommentRepository: AudioContentCommentRepository, @Value("\${cloud.naver.papago-client-id}") private val papagoClientId: String, @@ -48,13 +57,26 @@ class LanguageDetectListener( @Transactional(propagation = Propagation.REQUIRES_NEW) fun detectLanguage(event: LanguageDetectEvent) { if (event.query.isBlank()) { - log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. contentId={}", event.contentId) + log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event) return } - val audioContent = audioContentRepository.findById(event.contentId).orElse(null) + when (event.targetType) { + LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event) + LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event) + } + } + + private fun handleContentLanguageDetect(event: LanguageDetectEvent) { + val contentId = event.contentId + if (contentId == null) { + log.warn("[PapagoLanguageDetect] contentId is null for CONTENT target. event={}", event) + return + } + + val audioContent = audioContentRepository.findById(contentId).orElse(null) if (audioContent == null) { - log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", event.contentId) + log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId) return } @@ -62,13 +84,63 @@ class LanguageDetectListener( if (!audioContent.languageCode.isNullOrBlank()) { log.debug( "[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}", - event.contentId, + contentId, audioContent.languageCode ) return } - try { + val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return + + audioContent.languageCode = langCode + + // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. + audioContentRepository.save(audioContent) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", + contentId, + langCode + ) + } + + private fun handleCommentLanguageDetect(event: LanguageDetectEvent) { + val commentId = event.commentId + if (commentId == null) { + log.warn("[PapagoLanguageDetect] commentId is null for COMMENT target. event={}", event) + return + } + + val comment = audioContentCommentRepository.findById(commentId).orElse(null) + if (comment == null) { + log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!comment.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}", + commentId, + comment.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return + + comment.languageCode = langCode + audioContentCommentRepository.save(comment) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}", + commentId, + langCode + ) + } + + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { + return try { val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_FORM_URLENCODED set("X-NCP-APIGW-API-KEY-ID", papagoClientId) @@ -76,8 +148,8 @@ class LanguageDetectListener( } val body = LinkedMultiValueMap().apply { - // 파파고 스펙에 따라 query 파라미터에 제목/내용/태그를 공백으로 구분하여 전달 - add("query", event.query) + // 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달 + add("query", query) } val requestEntity = HttpEntity(body, headers) @@ -90,32 +162,31 @@ class LanguageDetectListener( if (!response.statusCode.is2xxSuccessful) { log.warn( - "[PapagoLanguageDetect] Non-success status from Papago. status={}, contentId={}", + "[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}", response.statusCode, - event.contentId + targetIdForLog ) - return + return null } 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 + log.warn( + "[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}", + targetIdForLog + ) + return null } - audioContent.languageCode = langCode - - // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. - audioContentRepository.save(audioContent) - - log.info( - "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", - event.contentId, - langCode - ) + langCode } catch (ex: Exception) { // 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다. - log.error("[PapagoLanguageDetect] Failed to detect language via Papago. contentId={}", event.contentId, ex) + log.error( + "[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}", + targetIdForLog, + ex + ) + null } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index cb5b1967..28f37172 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType @@ -86,6 +88,17 @@ class AudioContentCommentService( ) ) + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + commentId = savedContentComment.id!!, + query = comment, + targetType = LanguageDetectTargetType.COMMENT + ) + ) + } + return savedContentComment.id!! } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt index f92634de..26cc8d67 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -4,9 +4,12 @@ import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.member.Member +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional class AudioContentDonationService( private val canPaymentService: CanPaymentService, private val queryRepository: AudioContentRepository, - private val commentRepository: AudioContentCommentRepository + private val commentRepository: AudioContentCommentRepository, + private val applicationEventPublisher: ApplicationEventPublisher ) { @Transactional fun donation(request: AudioContentDonationRequest, member: Member) { @@ -39,6 +43,18 @@ class AudioContentDonationService( ) audioContentComment.audioContent = audioContent audioContentComment.member = member - commentRepository.save(audioContentComment) + + val savedComment = commentRepository.save(audioContentComment) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (request.languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + commentId = savedComment.id!!, + query = request.comment, + targetType = LanguageDetectTargetType.COMMENT + ) + ) + } } } -- 2.49.1 From a2998002e5c242292ce7812d291be5a26b0fb870 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:10:20 +0900 Subject: [PATCH 06/90] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=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 --- .../chat/character/comment/CharacterComment.kt | 1 + .../comment/CharacterCommentController.kt | 2 +- .../chat/character/comment/CharacterCommentDto.kt | 3 ++- .../character/comment/CharacterCommentService.kt | 14 ++++++++++---- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt index 62f1cb94..f2d9f87b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt @@ -16,6 +16,7 @@ import javax.persistence.Table data class CharacterComment( @Column(columnDefinition = "TEXT", nullable = false) var comment: String, + var languageCode: String?, var isActive: Boolean = true ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt index 8036169b..e9fcc649 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -47,7 +47,7 @@ class CharacterCommentController( if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") - val id = service.addReply(characterId, commentId, member, request.comment) + val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode) ApiResponse.ok(id) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index d35f2cb2..2c898e64 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment // Request DTOs data class CreateCharacterCommentRequest( - val comment: String + val comment: String, + val languageCode: String? = null ) // Response DTOs diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 25d186a4..012aa7a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -57,12 +57,12 @@ class CharacterCommentService( } @Transactional - fun addComment(characterId: Long, member: Member, text: String): Long { + fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long { val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") - val entity = CharacterComment(comment = text) + val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character entity.member = member commentRepository.save(entity) @@ -70,7 +70,13 @@ class CharacterCommentService( } @Transactional - fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long { + fun addReply( + characterId: Long, + parentCommentId: Long, + member: Member, + text: String, + languageCode: String? = null + ): Long { val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } @@ -78,7 +84,7 @@ class CharacterCommentService( if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.") if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") - val entity = CharacterComment(comment = text) + val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character entity.member = member entity.parent = parent -- 2.49.1 From 619ceeea248054c0e21f3107dd3714703ba9554a Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:19:08 +0900 Subject: [PATCH 07/90] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EA=B0=80=20?= =?UTF-8?q?null=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=ED=8C=8C=ED=8C=8C?= =?UTF-8?q?=EA=B3=A0=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20API?= =?UTF-8?q?=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=98=EB=8A=94=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 --- .../comment/CharacterCommentService.kt | 30 ++++++++++++- .../sodalive/content/LanguageDetectEvent.kt | 42 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 012aa7a7..bf5e49ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.chat.character.comment import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.member.Member +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,7 +15,8 @@ import java.time.ZoneId class CharacterCommentService( private val chatCharacterRepository: ChatCharacterRepository, private val commentRepository: CharacterCommentRepository, - private val reportRepository: CharacterCommentReportRepository + private val reportRepository: CharacterCommentReportRepository, + private val applicationEventPublisher: ApplicationEventPublisher ) { private fun profileUrl(imageHost: String, profileImage: String?): String { @@ -66,6 +70,18 @@ class CharacterCommentService( entity.chatCharacter = character entity.member = member commentRepository.save(entity) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + commentId = entity.id!!, + query = text, + targetType = LanguageDetectTargetType.CHARACTER_COMMENT + ) + ) + } + return entity.id!! } @@ -89,6 +105,18 @@ class CharacterCommentService( entity.member = member entity.parent = parent commentRepository.save(entity) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + commentId = entity.id!!, + query = text, + targetType = LanguageDetectTargetType.CHARACTER_COMMENT + ) + ) + } + return entity.id!! } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 4321b00c..7ba9a75b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.content +import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -20,7 +21,8 @@ import org.springframework.web.client.RestTemplate */ enum class LanguageDetectTargetType { CONTENT, - COMMENT + COMMENT, + CHARACTER_COMMENT } class LanguageDetectEvent( @@ -38,6 +40,7 @@ data class PapagoLanguageDetectResponse( class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, private val audioContentCommentRepository: AudioContentCommentRepository, + private val characterCommentRepository: CharacterCommentRepository, @Value("\${cloud.naver.papago-client-id}") private val papagoClientId: String, @@ -64,6 +67,7 @@ class LanguageDetectListener( when (event.targetType) { LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event) LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event) + LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) } } @@ -139,6 +143,42 @@ class LanguageDetectListener( ) } + private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) { + val commentId = event.commentId + if (commentId == null) { + log.warn("[PapagoLanguageDetect] commentId is null for CHARACTER_COMMENT target. event={}", event) + return + } + + val comment = characterCommentRepository.findById(commentId).orElse(null) + if (comment == null) { + log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!comment.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. " + + "characterCommentId={}, languageCode={}", + commentId, + comment.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return + + comment.languageCode = langCode + characterCommentRepository.save(comment) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}", + commentId, + langCode + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { -- 2.49.1 From 8f4544ad7191a79cc10ab0bb3fa9ee25852704f4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:32:29 +0900 Subject: [PATCH 08/90] =?UTF-8?q?refactor(lang-detect):=20LanguageDetectEv?= =?UTF-8?q?ent=20ID=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EB=8B=A8=EC=9D=BC=20i?= =?UTF-8?q?d=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LanguageDetectEvent의 contentId/commentId를 제거하고 공통 id(Long) 필드로 단순화 - LanguageDetectListener에서 targetType에 따라 id를 AudioContent/AudioContentComment/CharacterComment 조회에 사용하도록 수정 - AudioContentService, AudioContentCommentService, AudioContentDonationService, CharacterCommentService 등 이벤트 발행부를 새 시그니처(id + targetType)로 정리 --- .../comment/CharacterCommentService.kt | 4 ++-- .../sodalive/content/AudioContentService.kt | 2 +- .../sodalive/content/LanguageDetectEvent.kt | 23 ++++--------------- .../comment/AudioContentCommentService.kt | 2 +- .../donation/AudioContentDonationService.kt | 2 +- 5 files changed, 10 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index bf5e49ab..425123cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -75,7 +75,7 @@ class CharacterCommentService( if (languageCode.isNullOrBlank()) { applicationEventPublisher.publishEvent( LanguageDetectEvent( - commentId = entity.id!!, + id = entity.id!!, query = text, targetType = LanguageDetectTargetType.CHARACTER_COMMENT ) @@ -110,7 +110,7 @@ class CharacterCommentService( if (languageCode.isNullOrBlank()) { applicationEventPublisher.publishEvent( LanguageDetectEvent( - commentId = entity.id!!, + id = entity.id!!, query = text, targetType = LanguageDetectTargetType.CHARACTER_COMMENT ) 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 725dfe4d..4b64d8aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -344,7 +344,7 @@ class AudioContentService( applicationEventPublisher.publishEvent( LanguageDetectEvent( - contentId = audioContent.id!!, + id = audioContent.id!!, query = papagoQuery ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 7ba9a75b..24d2c0b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -26,10 +26,9 @@ enum class LanguageDetectTargetType { } class LanguageDetectEvent( - val contentId: Long? = null, + val id: Long, val query: String, - val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT, - val commentId: Long? = null + val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT ) data class PapagoLanguageDetectResponse( @@ -72,11 +71,7 @@ class LanguageDetectListener( } private fun handleContentLanguageDetect(event: LanguageDetectEvent) { - val contentId = event.contentId - if (contentId == null) { - log.warn("[PapagoLanguageDetect] contentId is null for CONTENT target. event={}", event) - return - } + val contentId = event.id val audioContent = audioContentRepository.findById(contentId).orElse(null) if (audioContent == null) { @@ -109,11 +104,7 @@ class LanguageDetectListener( } private fun handleCommentLanguageDetect(event: LanguageDetectEvent) { - val commentId = event.commentId - if (commentId == null) { - log.warn("[PapagoLanguageDetect] commentId is null for COMMENT target. event={}", event) - return - } + val commentId = event.id val comment = audioContentCommentRepository.findById(commentId).orElse(null) if (comment == null) { @@ -144,11 +135,7 @@ class LanguageDetectListener( } private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) { - val commentId = event.commentId - if (commentId == null) { - log.warn("[PapagoLanguageDetect] commentId is null for CHARACTER_COMMENT target. event={}", event) - return - } + val commentId = event.id val comment = characterCommentRepository.findById(commentId).orElse(null) if (comment == null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 28f37172..c8663a28 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -92,7 +92,7 @@ class AudioContentCommentService( if (languageCode.isNullOrBlank()) { applicationEventPublisher.publishEvent( LanguageDetectEvent( - commentId = savedContentComment.id!!, + id = savedContentComment.id!!, query = comment, targetType = LanguageDetectTargetType.COMMENT ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt index 26cc8d67..99a4da50 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -50,7 +50,7 @@ class AudioContentDonationService( if (request.languageCode.isNullOrBlank()) { applicationEventPublisher.publishEvent( LanguageDetectEvent( - commentId = savedComment.id!!, + id = savedComment.id!!, query = request.comment, targetType = LanguageDetectTargetType.COMMENT ) -- 2.49.1 From 412c52e75475eb0615d107d1cd4fa2b9bd4c78f6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:36:39 +0900 Subject: [PATCH 09/90] =?UTF-8?q?feat(creator-cheers):=20=ED=8C=AC=20Talk?= =?UTF-8?q?=20=EC=9D=91=EC=9B=90=EA=B8=80=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C?= =?UTF-8?q?=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20=EA=B8=B0=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 --- .../kr/co/vividnext/sodalive/explorer/ExplorerService.kt | 2 +- .../kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt | 1 + .../sodalive/explorer/profile/PostWriteCheersRequest.kt | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 75362694..933a8a8b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -441,7 +441,7 @@ class ExplorerService( val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId) if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.") - val cheers = CreatorCheers(cheers = request.content) + val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode) cheers.member = member cheers.creator = creator diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt index a7d7e54e..8aa260c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorCheers.kt @@ -13,6 +13,7 @@ import javax.persistence.OneToMany data class CreatorCheers( @Column(columnDefinition = "TEXT", nullable = false) var cheers: String, + var languageCode: String?, var isActive: Boolean = true ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt index 9d75c7ff..81969c6b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/PostWriteCheersRequest.kt @@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile data class PostWriteCheersRequest( val parentId: Long? = null, val creatorId: Long, - val content: String + val content: String, + val languageCode: String? = null ) -- 2.49.1 From c5fa260a0d768163b632746d865a617f0c7389e7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 16:42:26 +0900 Subject: [PATCH 10/90] =?UTF-8?q?feat(creator-cheers):=20=ED=8C=AC=20Talk?= =?UTF-8?q?=20=EC=9D=91=EC=9B=90=EA=B8=80=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C?= =?UTF-8?q?=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=EA=B0=80=20null?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=ED=8C=8C=ED=8C=8C=EA=B3=A0=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20API=EB=A5=BC=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/LanguageDetectEvent.kt | 37 ++++++++++++++++++- .../sodalive/explorer/ExplorerService.kt | 13 +++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 24d2c0b9..feb06c62 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpEntity @@ -22,7 +23,8 @@ import org.springframework.web.client.RestTemplate enum class LanguageDetectTargetType { CONTENT, COMMENT, - CHARACTER_COMMENT + CHARACTER_COMMENT, + CREATOR_CHEERS } class LanguageDetectEvent( @@ -40,6 +42,7 @@ class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, private val audioContentCommentRepository: AudioContentCommentRepository, private val characterCommentRepository: CharacterCommentRepository, + private val creatorCheersRepository: CreatorCheersRepository, @Value("\${cloud.naver.papago-client-id}") private val papagoClientId: String, @@ -67,6 +70,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event) LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) + LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) } } @@ -166,6 +170,37 @@ class LanguageDetectListener( ) } + private fun handleCreatorCheersLanguageDetect(event: LanguageDetectEvent) { + val cheersId = event.id + + val cheers = creatorCheersRepository.findById(cheersId).orElse(null) + if (cheers == null) { + log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!cheers.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}", + cheersId, + cheers.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return + + cheers.languageCode = langCode + creatorCheersRepository.save(cheers) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}", + cheersId, + langCode + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 933a8a8b..759c83f9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.explorer import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse @@ -456,6 +458,17 @@ class ExplorerService( } cheersRepository.save(cheers) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (request.languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = cheers.id!!, + query = request.content, + targetType = LanguageDetectTargetType.CREATOR_CHEERS + ) + ) + } } fun getCreatorProfileCheers( -- 2.49.1 From ddd46d585e854f73b3e59c84038aae01fb1dfca7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 18:05:08 +0900 Subject: [PATCH 11/90] =?UTF-8?q?feat(creator-cheers):=20=ED=8C=AC=20Talk?= =?UTF-8?q?=20=EC=9D=91=EC=9B=90=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EC=97=90=20=EC=96=B8=EC=96=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt | 2 ++ .../kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 4c1bacd2..dfce6763 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -488,6 +488,7 @@ class ExplorerQueryRepository( "$cloudFrontHost/profile/default-profile.png" }, content = it.cheers, + languageCode = it.languageCode, date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), replyList = it.children.asSequence() .map { cheers -> @@ -505,6 +506,7 @@ class ExplorerQueryRepository( "$cloudFrontHost/profile/default-profile.png" }, content = cheers.cheers, + languageCode = cheers.languageCode, date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), replyList = listOf() ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt index 5465295f..5192ca72 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/GetCheersResponse.kt @@ -11,6 +11,7 @@ data class GetCheersResponseItem( val nickname: String, val profileUrl: String, val content: String, + val languageCode: String?, val date: String, val replyList: List ) -- 2.49.1 From 8ec6d50dd82727c50857a741c8eb0ee58e5ccf47 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 18:10:36 +0900 Subject: [PATCH 12/90] =?UTF-8?q?feat(content-comment):=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/comment/AudioContentCommentRepository.kt | 2 ++ .../content/comment/GetAudioContentCommentListResponse.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt index b49c2095..09ef520e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentRepository.kt @@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl( audioContentComment.member.nickname, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, + audioContentComment.languageCode, audioContentComment.isSecret, audioContentComment.donationCan.coalesce(0), formattedDate, @@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl( audioContentComment.member.nickname, audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost), audioContentComment.comment, + audioContentComment.languageCode, audioContentComment.isSecret, audioContentComment.donationCan.coalesce(0), formattedDate, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt index 37bd46aa..b0d11605 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/GetAudioContentCommentListResponse.kt @@ -13,6 +13,7 @@ data class GetAudioContentCommentListItem @QueryProjection constructor( val nickname: String, val profileUrl: String, val comment: String, + val languageCode: String?, val isSecret: Boolean, val donationCan: Int, val date: String, -- 2.49.1 From 62ec994069f12d29b3f76f1675464bf8aadb91bc Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 18:12:50 +0900 Subject: [PATCH 13/90] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=8B=9C=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/comment/CharacterCommentDto.kt | 3 ++- .../sodalive/chat/character/comment/CharacterCommentService.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index 2c898e64..cbfabd10 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -21,7 +21,8 @@ data class CharacterCommentResponse( val memberNickname: String, val createdAt: Long, val replyCount: Int, - val comment: String + val comment: String, + val languageCode: String? ) // 답글 Response 단건(목록 원소) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 425123cb..0ba65e53 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -44,7 +44,8 @@ class CharacterCommentService( memberNickname = member.nickname, createdAt = toEpochMilli(entity.createdAt), replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!), - comment = entity.comment + comment = entity.comment, + languageCode = entity.languageCode ) } -- 2.49.1 From e0dcbd16fcbb306db6ed411895d87f370ab806fb Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 25 Nov 2025 19:35:59 +0900 Subject: [PATCH 14/90] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=EC=9D=98=20=EB=8B=B5?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/comment/CharacterCommentDto.kt | 3 ++- .../sodalive/chat/character/comment/CharacterCommentService.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index cbfabd10..103f088e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -37,7 +37,8 @@ data class CharacterReplyResponse( val memberProfileImage: String, val memberNickname: String, val createdAt: Long, - val comment: String + val comment: String, + val languageCode: String? ) // 댓글의 답글 조회 Response 컨테이너 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 0ba65e53..1be90668 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -57,7 +57,8 @@ class CharacterCommentService( memberProfileImage = profileUrl(imageHost, member.profileImage), memberNickname = member.nickname, createdAt = toEpochMilli(entity.createdAt), - comment = entity.comment + comment = entity.comment, + languageCode = entity.languageCode ) } -- 2.49.1 From 899f2865b31fe480523a1f7241f5adc948ac5231 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 26 Nov 2025 11:40:58 +0900 Subject: [PATCH 15/90] =?UTF-8?q?feat(chat-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=93=B1=EB=A1=9D=EC=8B=9C=20=ED=8C=8C?= =?UTF-8?q?=ED=8C=8C=EA=B3=A0=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?API=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=98=EC=97=AC=20languageC?= =?UTF-8?q?ode=EB=A5=BC=20=EA=B8=B0=EB=A1=9D=ED=95=98=EB=8A=94=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 --- .../character/AdminChatCharacterController.kt | 16 +++++++++ .../sodalive/chat/character/ChatCharacter.kt | 2 ++ .../sodalive/content/LanguageDetectEvent.kt | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index dfa8c3a2..21a7bda1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -13,8 +13,11 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -40,6 +43,7 @@ class AdminChatCharacterController( private val adminService: AdminChatCharacterService, private val s3Uploader: S3Uploader, private val originalWorkService: AdminOriginalWorkService, + private val applicationEventPublisher: ApplicationEventPublisher, @Value("\${weraser.api-key}") private val apiKey: String, @@ -165,6 +169,18 @@ class AdminChatCharacterController( originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!) } + // 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + // 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다. + if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = chatCharacter.id!!, + query = chatCharacter.description, + targetType = LanguageDetectTargetType.CHARACTER + ) + ) + } + ApiResponse.ok(null) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 981b6f10..5a628513 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -22,6 +22,8 @@ class ChatCharacter( // 캐릭터 한 줄 소개 var description: String, + var languageCode: String? = null, + // AI 시스템 프롬프트 @Column(columnDefinition = "TEXT", nullable = false) var systemPrompt: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index feb06c62..64a3bfb2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import org.slf4j.LoggerFactory @@ -23,6 +24,7 @@ import org.springframework.web.client.RestTemplate enum class LanguageDetectTargetType { CONTENT, COMMENT, + CHARACTER, CHARACTER_COMMENT, CREATOR_CHEERS } @@ -41,6 +43,7 @@ data class PapagoLanguageDetectResponse( class LanguageDetectListener( private val audioContentRepository: AudioContentRepository, private val audioContentCommentRepository: AudioContentCommentRepository, + private val chatCharacterRepository: ChatCharacterRepository, private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, @@ -69,11 +72,43 @@ class LanguageDetectListener( when (event.targetType) { LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event) LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event) + LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) } } + private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) { + val characterId = event.id + + val character = chatCharacterRepository.findById(characterId).orElse(null) + if (character == null) { + log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!character.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}", + characterId, + character.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return + + character.languageCode = langCode + chatCharacterRepository.save(character) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}", + characterId, + langCode + ) + } + private fun handleContentLanguageDetect(event: LanguageDetectEvent) { val contentId = event.id -- 2.49.1 From 503802bcce50cab42fffdb2579d75041a422a238 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 26 Nov 2025 11:53:40 +0900 Subject: [PATCH 16/90] =?UTF-8?q?feat(chat-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20=EC=96=B8=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/controller/ChatCharacterController.kt | 1 + .../sodalive/chat/character/dto/CharacterDetailResponse.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index adfb1efe..5d51f865 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -171,6 +171,7 @@ class ChatCharacterController( characterId = character.id!!, name = character.name, description = character.description, + languageCode = character.languageCode, mbti = character.mbti, gender = character.gender, age = character.age, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index 1f5c6c50..59a594a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -7,6 +7,7 @@ data class CharacterDetailResponse( val characterId: Long, val name: String, val description: String, + val languageCode: String?, val mbti: String?, val gender: String?, val age: Int?, -- 2.49.1 From 7b0644cb66f3d27c2ec0c830dd366c095d517681 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 9 Dec 2025 14:52:11 +0900 Subject: [PATCH 17/90] =?UTF-8?q?AGENTS.md=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 18 ++++++ work/scripts/check-commit-message-rules.sh | 71 ++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 AGENTS.md create mode 100755 work/scripts/check-commit-message-rules.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8cf6285b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +질문에 대한 답변과 설명은 한국어로 한다. + +## Quality Assurance Guidelines + +### Commit Standards +1. Write in Korean. +2. Use the present tense in the subject line (e.g., "Add feature" not "Added feature"). +3. Keep the subject line to 50 characters or less. +4. Add a blank line between the subject and body. +5. Keep the body to 72 characters or less per line. +6. Within a paragraph, only break lines when the text exceeds 72 characters. +7. Describe changes to public API features and do not include implementation details such as package-private code. +8. Do not mention test code in commit messages. +9. Do not use any prefix (such as "fix:", "update:", "docs:", "feat:", etc.) in the subject line. +10. Do not start the subject line with a lowercase letter unless the first word is a function name or another identifier that is conventionally lowercase and there is a clear, justifiable reason for the exception. Otherwise, always start with an uppercase letter. +11. Do not include tool advertisements, branding, or promotional content in commit messages. +12. Use separate git commands to stage files before committing. +13. Always validate commits using `work/scripts/check-commit-message-rules.sh` and fix until validation passes. diff --git a/work/scripts/check-commit-message-rules.sh b/work/scripts/check-commit-message-rules.sh new file mode 100755 index 00000000..db3a9a14 --- /dev/null +++ b/work/scripts/check-commit-message-rules.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# Check if a commit message follows project rules +# Rules: 50/72 formatting, no advertisements/branding +# Usage: ./check-commit-message-rules.sh [commit-hash] +# If no commit-hash is provided, checks the latest commit + +# Determine which commit to check +if [ $# -eq 0 ]; then + commit_ref="HEAD" + echo "Checking latest commit..." +else + commit_ref="$1" + echo "Checking commit: $commit_ref" +fi + +# Get the commit message +commit_message=$(git log -1 --pretty=format:"%s%n%b" "$commit_ref") + +# Split into subject and body +subject=$(echo "$commit_message" | head -n1) +body=$(echo "$commit_message" | tail -n +2 | sed '/^$/d') + +echo "Checking commit message format..." +echo "Subject: $subject" + +# Check subject line length +subject_length=${#subject} +if [ $subject_length -gt 50 ]; then + echo "[FAIL] Subject line too long: $subject_length characters (max 50)" + exit_code=1 +else + echo "[PASS] Subject line length OK: $subject_length characters" + exit_code=0 +fi + +# Check body line lengths if body exists +if [ -n "$body" ]; then + echo "Checking body line lengths..." + while IFS= read -r line; do + line_length=${#line} + if [ $line_length -gt 72 ]; then + echo "[FAIL] Body line too long: $line_length characters (max 72)" + echo "Line: $line" + exit_code=1 + fi + done <<< "$body" + + if [ $exit_code -eq 0 ]; then + echo "[PASS] All body lines within 72 characters" + fi +else + echo "[INFO] No body content to check" +fi + +# Check for advertisements, branding, or promotional content +echo "Checking for advertisements and branding..." +if echo "$commit_message" | grep -qi "generated with\|claude code\|anthropic\|co-authored-by.*claude\|🤖"; then + echo "[FAIL] Commit message contains advertisements, branding, or promotional content" + exit_code=1 +else + echo "[PASS] No advertisements or branding detected" +fi + +if [ $exit_code -eq 0 ]; then + echo "[PASS] Commit message follows all rules" +else + echo "[FAIL] Commit message violates project rules" +fi + +exit $exit_code -- 2.49.1 From 668d4f28cdcd769859722c61ca00a39e2f539d17 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 10 Dec 2025 00:06:51 +0900 Subject: [PATCH 18/90] =?UTF-8?q?AGENTS.md=20=ED=8C=8C=EC=9D=BC=EC=97=90?= =?UTF-8?q?=20AI=20Coding=20Agent=EA=B0=80=20=EB=B0=98=EB=93=9C=EC=8B=9C?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EC=95=BC=20=ED=95=A0=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=20=ED=97=8C=EB=B2=95(=EC=9A=B4=EC=98=81=20=EA=B7=9C=EC=B9=99)?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=ED=95=98=EA=B2=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 243 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8cf6285b..f4e6615c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,246 @@ +# AGENTS.md + +> 이 문서는 본 저장소에서 **AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙)**이다. +> 모든 신규 코드는 본 문서를 최우선 기준으로 작성한다. + +--- + +## 0. 전제 질문에 대한 답변과 설명은 한국어로 한다. -## Quality Assurance Guidelines +--- -### Commit Standards -1. Write in Korean. -2. Use the present tense in the subject line (e.g., "Add feature" not "Added feature"). -3. Keep the subject line to 50 characters or less. -4. Add a blank line between the subject and body. -5. Keep the body to 72 characters or less per line. -6. Within a paragraph, only break lines when the text exceeds 72 characters. -7. Describe changes to public API features and do not include implementation details such as package-private code. -8. Do not mention test code in commit messages. -9. Do not use any prefix (such as "fix:", "update:", "docs:", "feat:", etc.) in the subject line. -10. Do not start the subject line with a lowercase letter unless the first word is a function name or another identifier that is conventionally lowercase and there is a clear, justifiable reason for the exception. Otherwise, always start with an uppercase letter. -11. Do not include tool advertisements, branding, or promotional content in commit messages. -12. Use separate git commands to stage files before committing. -13. Always validate commits using `work/scripts/check-commit-message-rules.sh` and fix until validation passes. +## 1. 역할과 전문성 (ROLE AND EXPERTISE) + +AI Coding Agent는 **Kent Beck의 TDD(Test-Driven Development)** 와 **Tidy First** 원칙을 엄격히 따르는 **시니어 소프트웨어 엔지니어 역할**을 수행한다. +본 프로젝트의 모든 개발은 이 두 원칙을 정확하게 기반으로 한다. + +--- + +## 2. 절대 금지 사항 (Strict Prohibitions) ❗최우선 + +AI Coding Agent는 다음 행위를 절대 해서는 안 된다. + +- 레거시 패키지 수정 금지 +- UseCase에서 JPA Repository 직접 주입 금지 +- Port를 우회하여 Infrastructure 직접 호출 금지 +- Command와 Query 책임 혼합 금지 +- 전역 공유 상태(Global State) 생성 금지 +- 명시적 지시 없이 새로운 아키텍처 패턴 도입 금지 + +--- + +## 3. 프로젝트 개요 (Project Overview) + +- 본 프로젝트는 **Kotlin + Spring Boot 기반 Modular Monolith** 이다. +- 기존 레거시 패키지는 **절대 수정하지 않는다.** +- **모든 신규 기능은 반드시 `modules` 패키지 하위에서만 개발한다.** +- 본 프로젝트는 아래 아키텍처 원칙을 따른다. + - Clean Architecture + - CQRS (Command / Query 분리) + - Port & Adapter 패턴 + +--- + +## 4. 패키지 & 모듈 구조 규칙 (Package & Module Rules) + +- 모든 신규 도메인은 아래 규칙을 따른다. + +``` +kr.co.vividnext.sodalive.modules.{domain} +``` + +- 각 도메인은 다음 구조를 기본으로 한다. + +``` +api/ +application/ + command/ + query/ +domain/ +infrastructure/ +``` + +- 레거시 패키지는 **절대 리팩터링하거나 구조를 변경하지 않는다.** + +--- + +## 5. UseCase & 네이밍 규칙 (UseCase & Naming Rules) + +- UseCase는 **단 하나의 비즈니스 행동만 표현한다.** +- UseCase는 반드시 `operator fun invoke()` 형식을 사용하고 `execute()`는 사용하지 않는다. + +```kotlin +class SignUpUseCase { + operator fun invoke(command: SignUpCommand) +} +``` + +- Command 네이밍 규칙 + - SignUpCommand + - CreateOrderCommand + - ChangePasswordCommand + +- Query는 항상 **불변(Immutable) Response 객체**를 반환한다. + +--- + +## 6. Port & Adapter 규칙 (Port & Adapter Rules) + +- Port는 반드시 Application Layer에 위치해야 한다. +- Adapter는 반드시 Infrastructure Layer에 위치해야 한다. +- UseCase는 **구현체에 절대 의존하지 않고 오직 Port에만 의존한다.** +- Repository는 Adapter의 한 종류이다. + +--- + +## 7. 테스트 전략 (Testing Strategy) + +본 프로젝트는 **Mock 최소화, Fake/DB 중심 테스트 전략**을 따른다. + +### 1단계: UseCase 테스트 +- FakePort 사용 우선 +- Spring Context 사용 금지 +- 비즈니스 규칙만 검증 + +### 2단계: Repository 테스트 +- H2 DB 사용 +- JPA 매핑 및 기본 쿼리 검증 + +### 3단계: 통합 테스트 +- H2 DB 사용 + +### 4단계: E2E 테스트 +- TestRestTemplate 사용 +- **핵심 사용자 시나리오만 제한적으로 작성** + +- Mock은 **호출 횟수/분기 검증이 꼭 필요할 때만 제한적으로 허용한다.** + +--- + +## 8. 전략적 TDD 진입 원칙 (TDD Entry Strategy) + +TDD의 진입점은 **리스크 기반으로 선택한다.** + +- API 계약 / 보안 / 인프라 리스크 → **TestRestTemplate부터 시작** +- 비즈니스 규칙 / 도메인 로직 리스크 → **UseCase + FakePort부터 시작** + +TDD의 목적은 **커버리지가 아니라 다음 3가지를 확보하는 것이다.** +- 리스크 통제 +- 회귀 방지 +- 리팩터링 신뢰도 확보 + +--- + +## 9. 핵심 TDD & 개발 원칙 (Core Development Principles) + +- 반드시 **TDD 사이클(Red → Green → Refactor)** 을 따른다. +- 항상 **가장 단순한 실패 테스트부터 작성한다.** +- 테스트를 통과시키는 **최소한의 코드만 작성한다.** +- 테스트가 모두 통과한 뒤에만 리팩터링한다. +- **Tidy First 원칙을 반드시 따른다.** +- 구조 변경과 기능 변경을 반드시 분리한다. +- 가능한 가장 작은 단위로 작업하며, 각 단위마다 검토를 요청한다. +- 코드는 스스로 설명해야 하며 **주석은 요청이 있을 때만 작성한다.** +- 항상 높은 코드 품질을 유지한다. +- 테스트 이름은 반드시 **행동을 설명하는 의미론적 이름**을 사용한다. +- 테스트 실패 메시지는 반드시 **원인이 명확해야 한다.** + +--- + +## 10. 테스트 시나리오 작성 규칙 (Test Scenario Writing) + +테스트 시나리오는 다음 규칙을 반드시 따른다. + +1. 가장 중요한 시나리오부터 하나씩 작성한다. +2. 한 문장으로 작성한다. +3. 반드시 **한국어**로 작성한다. +4. **현재형**으로 작성한다. +5. 테스트 대상 시스템은 반드시 `sut`로 표기한다. +6. 테스트 메서드 이름으로 그대로 사용 가능해야 한다. +7. 의미를 유지하는 선에서 최대한 간결하게 작성한다. +8. 반드시 **할 일 형식(- [ ])** 으로 작성한다. + +예시: + +``` +- [ ] sut는 올바른 비밀번호로 로그인에 성공한다 +``` + +--- + +## 11. 표준 개발 워크플로우 (Tidy First 내장) + +신규 기능 개발 시 반드시 다음 순서를 따른다. + +- 본 워크플로우는 Tidy First 원칙에 따라 **STRUCTURAL CHANGES**와 **BEHAVIORAL CHANGES**를 명확히 분리한다. +- 하나의 커밋에는 두 변경 유형을 절대 혼합하지 않는다. + +1. 작은 실패 테스트 하나를 먼저 작성한다. +2. 해당 테스트를 통과시키는 최소 구현을 한다. +3. 테스트를 실행하여 Green 상태를 확인한다. +4. 필요한 구조 변경(Tidy First)을 수행하고 테스트를 다시 실행한다. +5. 구조 변경 커밋과 기능 변경 커밋을 분리하여 저장한다. +6. 다음 작은 기능 단위 테스트를 작성한다. +7. 이 과정을 반복하여 기능을 완성한다. + +--- + +## 12. 코드 품질 기준 (Code Quality Standards) + +- 중복 코드는 무자비하게 제거한다. +- 네이밍과 구조를 통해 의도를 명확히 표현한다. +- 모든 의존성은 명시적으로 드러나야 한다. +- 모든 메서드는 단일 책임만 가진다. +- 상태와 사이드 이펙트는 최소화한다. +- 항상 **"지금 당장 가장 단순하게 동작하는 방법"을 우선 선택한다.** + +--- + +## 13. 리팩터링 가이드라인 (Refactoring Guidelines) + +- 리팩터링은 반드시 **Green 상태에서만 수행한다.** +- 정식 명칭이 있는 리팩터링 패턴을 사용한다. +- 한 번에 오직 하나의 리팩터링만 수행한다. +- 매 리팩터링 이후 반드시 테스트를 실행한다. +- 중복 제거 및 가독성 향상 리팩터링을 최우선으로 한다. + +--- + +## 14. Custom Annotation 위치 규칙 + +- 전역 공통 애노테이션 → `common.annotation` +- 도메인 전용 애노테이션 → `modules/{domain}/annotation` +- Infrastructure 애노테이션 → `infrastructure.annotation` +- Test 전용 애노테이션 → `test/support/annotation` + +--- + +## 15. Commit Standards + +1. 커밋 메시지는 **반드시 한국어로 작성한다.** +2. 제목(subject)은 **현재형**으로 작성한다. (예: “기능 추가”, “기능 추가함” 금지) +3. 제목은 **50자 이내**로 작성한다. +4. 제목과 본문 사이에는 **반드시 한 줄 공백**을 둔다. +5. 본문은 **한 줄당 72자 이내**로 작성한다. +6. 하나의 문단에서는 72자를 초과할 때만 줄바꿈한다. +7. **공개 API 변경 사항만 설명**하며, 패키지 프라이빗 구현 상세는 포함하지 않는다. +8. **테스트 코드에 대한 언급은 커밋 메시지에 포함하지 않는다.** +9. 제목에 `fix:`, `feat:`, `docs:` 등의 **접두어를 사용하지 않는다.** +10. 제목은 **첫 글자를 대문자로 시작**한다. 단, 함수명 등 소문자가 합당한 경우만 예외를 허용한다. +11. 도구 광고, 브랜딩, 홍보성 문구를 **절대 포함하지 않는다.** +12. 커밋 전에는 **반드시 파일을 개별 stage 한다.** +13. 커밋 전 **`work/scripts/check-commit-message-rules.sh` 검증을 통과해야 한다.** + +--- + +## 16. AI 사용 규칙 (AI Interaction Rules) + +- 항상 테스트와 프로덕션 코드를 함께 생성한다. +- 매우 작은 단위의 변경만 수행한다. +- 대규모 리팩터링은 반드시 사전 승인을 요청한다. + +--- + +✅ 본 문서는 **AI와 사람이 동일한 개발 규칙을 공유하기 위한 최상위 기준 문서**이며, +✅ 모든 신규 코드는 본 문서를 기준으로 검토된다. -- 2.49.1 From fdac55ebdfc0638c55561ec8a95b278c3317e4c0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 15:50:52 +0900 Subject: [PATCH 19/90] =?UTF-8?q?AGENTS.md=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 246 ------------------------------------------------------ 1 file changed, 246 deletions(-) delete mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index f4e6615c..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,246 +0,0 @@ -# AGENTS.md - -> 이 문서는 본 저장소에서 **AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙)**이다. -> 모든 신규 코드는 본 문서를 최우선 기준으로 작성한다. - ---- - -## 0. 전제 -질문에 대한 답변과 설명은 한국어로 한다. - ---- - -## 1. 역할과 전문성 (ROLE AND EXPERTISE) - -AI Coding Agent는 **Kent Beck의 TDD(Test-Driven Development)** 와 **Tidy First** 원칙을 엄격히 따르는 **시니어 소프트웨어 엔지니어 역할**을 수행한다. -본 프로젝트의 모든 개발은 이 두 원칙을 정확하게 기반으로 한다. - ---- - -## 2. 절대 금지 사항 (Strict Prohibitions) ❗최우선 - -AI Coding Agent는 다음 행위를 절대 해서는 안 된다. - -- 레거시 패키지 수정 금지 -- UseCase에서 JPA Repository 직접 주입 금지 -- Port를 우회하여 Infrastructure 직접 호출 금지 -- Command와 Query 책임 혼합 금지 -- 전역 공유 상태(Global State) 생성 금지 -- 명시적 지시 없이 새로운 아키텍처 패턴 도입 금지 - ---- - -## 3. 프로젝트 개요 (Project Overview) - -- 본 프로젝트는 **Kotlin + Spring Boot 기반 Modular Monolith** 이다. -- 기존 레거시 패키지는 **절대 수정하지 않는다.** -- **모든 신규 기능은 반드시 `modules` 패키지 하위에서만 개발한다.** -- 본 프로젝트는 아래 아키텍처 원칙을 따른다. - - Clean Architecture - - CQRS (Command / Query 분리) - - Port & Adapter 패턴 - ---- - -## 4. 패키지 & 모듈 구조 규칙 (Package & Module Rules) - -- 모든 신규 도메인은 아래 규칙을 따른다. - -``` -kr.co.vividnext.sodalive.modules.{domain} -``` - -- 각 도메인은 다음 구조를 기본으로 한다. - -``` -api/ -application/ - command/ - query/ -domain/ -infrastructure/ -``` - -- 레거시 패키지는 **절대 리팩터링하거나 구조를 변경하지 않는다.** - ---- - -## 5. UseCase & 네이밍 규칙 (UseCase & Naming Rules) - -- UseCase는 **단 하나의 비즈니스 행동만 표현한다.** -- UseCase는 반드시 `operator fun invoke()` 형식을 사용하고 `execute()`는 사용하지 않는다. - -```kotlin -class SignUpUseCase { - operator fun invoke(command: SignUpCommand) -} -``` - -- Command 네이밍 규칙 - - SignUpCommand - - CreateOrderCommand - - ChangePasswordCommand - -- Query는 항상 **불변(Immutable) Response 객체**를 반환한다. - ---- - -## 6. Port & Adapter 규칙 (Port & Adapter Rules) - -- Port는 반드시 Application Layer에 위치해야 한다. -- Adapter는 반드시 Infrastructure Layer에 위치해야 한다. -- UseCase는 **구현체에 절대 의존하지 않고 오직 Port에만 의존한다.** -- Repository는 Adapter의 한 종류이다. - ---- - -## 7. 테스트 전략 (Testing Strategy) - -본 프로젝트는 **Mock 최소화, Fake/DB 중심 테스트 전략**을 따른다. - -### 1단계: UseCase 테스트 -- FakePort 사용 우선 -- Spring Context 사용 금지 -- 비즈니스 규칙만 검증 - -### 2단계: Repository 테스트 -- H2 DB 사용 -- JPA 매핑 및 기본 쿼리 검증 - -### 3단계: 통합 테스트 -- H2 DB 사용 - -### 4단계: E2E 테스트 -- TestRestTemplate 사용 -- **핵심 사용자 시나리오만 제한적으로 작성** - -- Mock은 **호출 횟수/분기 검증이 꼭 필요할 때만 제한적으로 허용한다.** - ---- - -## 8. 전략적 TDD 진입 원칙 (TDD Entry Strategy) - -TDD의 진입점은 **리스크 기반으로 선택한다.** - -- API 계약 / 보안 / 인프라 리스크 → **TestRestTemplate부터 시작** -- 비즈니스 규칙 / 도메인 로직 리스크 → **UseCase + FakePort부터 시작** - -TDD의 목적은 **커버리지가 아니라 다음 3가지를 확보하는 것이다.** -- 리스크 통제 -- 회귀 방지 -- 리팩터링 신뢰도 확보 - ---- - -## 9. 핵심 TDD & 개발 원칙 (Core Development Principles) - -- 반드시 **TDD 사이클(Red → Green → Refactor)** 을 따른다. -- 항상 **가장 단순한 실패 테스트부터 작성한다.** -- 테스트를 통과시키는 **최소한의 코드만 작성한다.** -- 테스트가 모두 통과한 뒤에만 리팩터링한다. -- **Tidy First 원칙을 반드시 따른다.** -- 구조 변경과 기능 변경을 반드시 분리한다. -- 가능한 가장 작은 단위로 작업하며, 각 단위마다 검토를 요청한다. -- 코드는 스스로 설명해야 하며 **주석은 요청이 있을 때만 작성한다.** -- 항상 높은 코드 품질을 유지한다. -- 테스트 이름은 반드시 **행동을 설명하는 의미론적 이름**을 사용한다. -- 테스트 실패 메시지는 반드시 **원인이 명확해야 한다.** - ---- - -## 10. 테스트 시나리오 작성 규칙 (Test Scenario Writing) - -테스트 시나리오는 다음 규칙을 반드시 따른다. - -1. 가장 중요한 시나리오부터 하나씩 작성한다. -2. 한 문장으로 작성한다. -3. 반드시 **한국어**로 작성한다. -4. **현재형**으로 작성한다. -5. 테스트 대상 시스템은 반드시 `sut`로 표기한다. -6. 테스트 메서드 이름으로 그대로 사용 가능해야 한다. -7. 의미를 유지하는 선에서 최대한 간결하게 작성한다. -8. 반드시 **할 일 형식(- [ ])** 으로 작성한다. - -예시: - -``` -- [ ] sut는 올바른 비밀번호로 로그인에 성공한다 -``` - ---- - -## 11. 표준 개발 워크플로우 (Tidy First 내장) - -신규 기능 개발 시 반드시 다음 순서를 따른다. - -- 본 워크플로우는 Tidy First 원칙에 따라 **STRUCTURAL CHANGES**와 **BEHAVIORAL CHANGES**를 명확히 분리한다. -- 하나의 커밋에는 두 변경 유형을 절대 혼합하지 않는다. - -1. 작은 실패 테스트 하나를 먼저 작성한다. -2. 해당 테스트를 통과시키는 최소 구현을 한다. -3. 테스트를 실행하여 Green 상태를 확인한다. -4. 필요한 구조 변경(Tidy First)을 수행하고 테스트를 다시 실행한다. -5. 구조 변경 커밋과 기능 변경 커밋을 분리하여 저장한다. -6. 다음 작은 기능 단위 테스트를 작성한다. -7. 이 과정을 반복하여 기능을 완성한다. - ---- - -## 12. 코드 품질 기준 (Code Quality Standards) - -- 중복 코드는 무자비하게 제거한다. -- 네이밍과 구조를 통해 의도를 명확히 표현한다. -- 모든 의존성은 명시적으로 드러나야 한다. -- 모든 메서드는 단일 책임만 가진다. -- 상태와 사이드 이펙트는 최소화한다. -- 항상 **"지금 당장 가장 단순하게 동작하는 방법"을 우선 선택한다.** - ---- - -## 13. 리팩터링 가이드라인 (Refactoring Guidelines) - -- 리팩터링은 반드시 **Green 상태에서만 수행한다.** -- 정식 명칭이 있는 리팩터링 패턴을 사용한다. -- 한 번에 오직 하나의 리팩터링만 수행한다. -- 매 리팩터링 이후 반드시 테스트를 실행한다. -- 중복 제거 및 가독성 향상 리팩터링을 최우선으로 한다. - ---- - -## 14. Custom Annotation 위치 규칙 - -- 전역 공통 애노테이션 → `common.annotation` -- 도메인 전용 애노테이션 → `modules/{domain}/annotation` -- Infrastructure 애노테이션 → `infrastructure.annotation` -- Test 전용 애노테이션 → `test/support/annotation` - ---- - -## 15. Commit Standards - -1. 커밋 메시지는 **반드시 한국어로 작성한다.** -2. 제목(subject)은 **현재형**으로 작성한다. (예: “기능 추가”, “기능 추가함” 금지) -3. 제목은 **50자 이내**로 작성한다. -4. 제목과 본문 사이에는 **반드시 한 줄 공백**을 둔다. -5. 본문은 **한 줄당 72자 이내**로 작성한다. -6. 하나의 문단에서는 72자를 초과할 때만 줄바꿈한다. -7. **공개 API 변경 사항만 설명**하며, 패키지 프라이빗 구현 상세는 포함하지 않는다. -8. **테스트 코드에 대한 언급은 커밋 메시지에 포함하지 않는다.** -9. 제목에 `fix:`, `feat:`, `docs:` 등의 **접두어를 사용하지 않는다.** -10. 제목은 **첫 글자를 대문자로 시작**한다. 단, 함수명 등 소문자가 합당한 경우만 예외를 허용한다. -11. 도구 광고, 브랜딩, 홍보성 문구를 **절대 포함하지 않는다.** -12. 커밋 전에는 **반드시 파일을 개별 stage 한다.** -13. 커밋 전 **`work/scripts/check-commit-message-rules.sh` 검증을 통과해야 한다.** - ---- - -## 16. AI 사용 규칙 (AI Interaction Rules) - -- 항상 테스트와 프로덕션 코드를 함께 생성한다. -- 매우 작은 단위의 변경만 수행한다. -- 대규모 리팩터링은 반드시 사전 승인을 요청한다. - ---- - -✅ 본 문서는 **AI와 사람이 동일한 개발 규칙을 공유하기 위한 최상위 기준 문서**이며, -✅ 모든 신규 코드는 본 문서를 기준으로 검토된다. -- 2.49.1 From 304c001a2743adf5c00f740953fb1cc31c77de80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 16:34:22 +0900 Subject: [PATCH 20/90] =?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 00000000..d0e0e29d --- /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 00000000..14197dbc --- /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 00000000..ff4fed60 --- /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 +) -- 2.49.1 From 8636a8cac064c98f30ca4db7e7d0d6157cf91106 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 17:19:00 +0900 Subject: [PATCH 21/90] =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EC=BA=90=EC=8B=9C=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 134 +++++++++++++++++- .../character/dto/CharacterDetailResponse.kt | 4 +- .../translate/AiCharacterTranslation.kt | 89 ++++++++++++ .../AiCharacterTranslationRepository.kt | 7 + 4 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 5d51f865..2f1dfa69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.controller import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse @@ -12,9 +13,17 @@ import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest @@ -32,7 +41,10 @@ class ChatCharacterController( private val bannerService: ChatCharacterBannerService, private val chatRoomService: ChatRoomService, private val characterCommentService: CharacterCommentService, - private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, + private val curationQueryService: CharacterCurationQueryService, + + private val translationService: PapagoTranslationService, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -119,6 +131,7 @@ class ChatCharacterController( @GetMapping("/{characterId}") fun getCharacterDetail( @PathVariable characterId: Long, + @RequestParam(required = false, defaultValue = "ko") languageCode: String? = "ko", @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") @@ -148,6 +161,122 @@ class ChatCharacterController( ) } + var translated: TranslatedAiCharacterDetail? = null + if (!languageCode.isNullOrBlank() && languageCode != character.languageCode) { + val locale = languageCode.lowercase() + + val existing = aiCharacterTranslationRepository + .findByCharacterIdAndLocale(character.id!!, locale) + + if (existing != null) { + val payload = existing.renderedPayload + translated = TranslatedAiCharacterDetail( + name = payload.name, + description = payload.description, + gender = payload.gender, + personality = TranslatedAiCharacterPersonality( + trait = payload.personalityTrait, + description = payload.personalityDescription + ).takeIf { + (it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true) + }, + background = TranslatedAiCharacterBackground( + topic = payload.backgroundTopic, + description = payload.backgroundDescription + ).takeIf { + (it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true) + }, + tags = payload.tags + ) + } else { + val texts = mutableListOf() + texts.add(character.name) + texts.add(character.description) + texts.add(character.gender ?: "") + + val hasPersonality = personality != null + if (hasPersonality) { + texts.add(personality!!.trait) + texts.add(personality.description) + } + + val hasBackground = background != null + if (hasBackground) { + texts.add(background!!.topic) + texts.add(background.description) + } + + texts.add(tags) + + val sourceLanguage = character.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 translatedName = translatedTexts[index++] + val translatedDescription = translatedTexts[index++] + val translatedGender = translatedTexts[index++] + + var translatedPersonality: TranslatedAiCharacterPersonality? = null + if (hasPersonality) { + translatedPersonality = TranslatedAiCharacterPersonality( + trait = translatedTexts[index++], + description = translatedTexts[index++] + ) + } + + var translatedBackground: TranslatedAiCharacterBackground? = null + if (hasBackground) { + translatedBackground = TranslatedAiCharacterBackground( + topic = translatedTexts[index++], + description = translatedTexts[index++] + ) + } + + val translatedTags = translatedTexts[index] + + val payload = AiCharacterTranslationRenderedPayload( + name = translatedName, + description = translatedDescription, + gender = translatedGender, + personalityTrait = translatedPersonality?.trait ?: "", + personalityDescription = translatedPersonality?.description ?: "", + backgroundTopic = translatedBackground?.topic ?: "", + backgroundDescription = translatedBackground?.description ?: "", + tags = translatedTags + ) + + val entity = AiCharacterTranslation( + characterId = character.id!!, + locale = locale, + translatedName = translatedName, + translatedTags = translatedTags, + renderedPayload = payload + ) + + aiCharacterTranslationRepository.save(entity) + + translated = TranslatedAiCharacterDetail( + name = translatedName, + description = translatedDescription, + gender = translatedGender, + personality = translatedPersonality, + background = translatedBackground, + tags = translatedTags + ) + } + } + } + // 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외) val others = service.getOtherCharactersBySharedTags(characterId, 10) .map { other -> @@ -184,7 +313,8 @@ class ChatCharacterController( characterType = character.characterType, others = others, latestComment = latestComment, - totalComments = characterCommentService.getTotalCommentCount(character.id!!) + totalComments = characterCommentService.getTotalCommentCount(character.id!!), + translated = translated ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index 59a594a4..aa3767d1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.dto import kr.co.vividnext.sodalive.chat.character.CharacterType import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail data class CharacterDetailResponse( val characterId: Long, @@ -20,7 +21,8 @@ data class CharacterDetailResponse( val characterType: CharacterType, val others: List, val latestComment: CharacterCommentResponse?, - val totalComments: Int + val totalComments: Int, + val translated: TranslatedAiCharacterDetail? ) data class OtherCharacter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt new file mode 100644 index 00000000..b168e0b7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -0,0 +1,89 @@ +package kr.co.vividnext.sodalive.chat.character.translate + +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 = ["character_id", "locale"]) + ] +) +class AiCharacterTranslation( + val characterId: Long, + val locale: String, + val translatedName: String, + val translatedTags: String, + + @Column(columnDefinition = "json") + @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) + val renderedPayload: AiCharacterTranslationRenderedPayload +) : BaseEntity() + +data class AiCharacterTranslationRenderedPayload( + val name: String, + val description: String, + val gender: String, + val personalityTrait: String, + val personalityDescription: String, + val backgroundTopic: String, + val backgroundDescription: String, + val tags: String +) + +@Converter(autoApply = false) +class AiCharacterTranslationRenderedPayloadConverter : + AttributeConverter { + + override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload { + if (dbData.isNullOrBlank()) { + return AiCharacterTranslationRenderedPayload( + name = "", + description = "", + gender = "", + personalityTrait = "", + personalityDescription = "", + backgroundTopic = "", + backgroundDescription = "", + tags = "" + ) + } + return objectMapper.readValue(dbData) + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} + +data class TranslatedAiCharacterDetail( + val name: String?, + val description: String?, + val gender: String?, + val personality: TranslatedAiCharacterPersonality?, + val background: TranslatedAiCharacterBackground?, + val tags: String? +) + +data class TranslatedAiCharacterPersonality( + val trait: String?, + val description: String? +) + +data class TranslatedAiCharacterBackground( + val topic: String?, + val description: String? +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt new file mode 100644 index 00000000..430f253b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.chat.character.translate + +import org.springframework.data.jpa.repository.JpaRepository + +interface AiCharacterTranslationRepository : JpaRepository { + fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation? +} -- 2.49.1 From 4498af45099cf1f79464245d2af10cd4d8576cd3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 18:19:10 +0900 Subject: [PATCH 22/90] Fix AI character translation unique constraint column --- .../sodalive/chat/character/translate/AiCharacterTranslation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt index b168e0b7..71725c55 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -14,7 +14,7 @@ import javax.persistence.UniqueConstraint @Entity @Table( uniqueConstraints = [ - UniqueConstraint(columnNames = ["character_id", "locale"]) + UniqueConstraint(columnNames = ["characterId", "locale"]) ] ) class AiCharacterTranslation( -- 2.49.1 From 3ff38bb73a35489787767cf79b521997f23cfeef Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 18:57:46 +0900 Subject: [PATCH 23/90] =?UTF-8?q?=ED=8C=8C=ED=8C=8C=EA=B3=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EC=8B=9C=20=EB=82=B4=EC=9A=A9=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=A0=20DELIMITER=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/i18n/translation/PapagoTranslationService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index 14197dbc..0d4dfcfc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -36,7 +36,7 @@ class PapagoTranslationService( val translatedTexts = mutableListOf() chunks.forEach { chunk -> - val textsInChunkCount = chunk.split(S3P_DELIMITER).size + val textsInChunkCount = chunk.split(DELIMITER).size try { val body = mapOf( @@ -61,7 +61,7 @@ class PapagoTranslationService( if (translated.isNullOrBlank()) { repeat(textsInChunkCount) { translatedTexts.add("") } } else { - translated.split(S3P_DELIMITER).forEach { translatedTexts.add(it) } + translated.split(DELIMITER).forEach { translatedTexts.add(it) } } } catch (_: Exception) { repeat(textsInChunkCount) { translatedTexts.add("") } @@ -91,7 +91,7 @@ class PapagoTranslationService( var endIndex = texts.size while (endIndex > startIndex) { val candidate = texts.subList(startIndex, endIndex) - val joined = candidate.joinToString(S3P_DELIMITER) + val joined = candidate.joinToString(DELIMITER) if (joined.length <= MAX_TEXT_LENGTH || endIndex - startIndex == 1) { chunks.add(joined) startIndex = endIndex @@ -121,7 +121,7 @@ class PapagoTranslationService( "it" ) - private const val S3P_DELIMITER = "__S3P__" + private const val DELIMITER = "\u001F\u001E\u001D\u001C\u001F\u001E\u001D\u001C" private const val MAX_TEXT_LENGTH = 3000 } } -- 2.49.1 From 1748b263189357c92bd73a2afa9a119e21ce2e90 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 19:35:05 +0900 Subject: [PATCH 24/90] =?UTF-8?q?=ED=8C=8C=ED=8C=8C=EA=B3=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EC=8B=9C=20=EB=82=B4=EC=9A=A9=EC=9D=84=20=ED=95=A9?= =?UTF-8?q?=EC=B3=90=EC=84=9C=20=ED=95=9C=EB=B2=88=EC=97=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EA=B0=9C?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20API=EB=A5=BC=20=ED=98=B8=EC=B6=9C=ED=95=B4?= =?UTF-8?q?=EC=84=9C=20=EB=B2=88=EC=97=AD=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/PapagoTranslationService.kt | 39 ++----------------- 1 file changed, 4 insertions(+), 35 deletions(-) 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 index 0d4dfcfc..6fb31fea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -20,7 +20,7 @@ class PapagoTranslationService( private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation" fun translate(request: TranslateRequest): TranslateResult { - if (request.texts.isEmpty()) { + if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) { return TranslateResult(emptyList()) } @@ -32,17 +32,14 @@ class PapagoTranslationService( set("X-NCP-APIGW-API-KEY", papagoClientSecret) } - val chunks = chunkTexts(request.texts) val translatedTexts = mutableListOf() - chunks.forEach { chunk -> - val textsInChunkCount = chunk.split(DELIMITER).size - + request.texts.forEach { text -> try { val body = mapOf( "source" to request.sourceLanguage, "target" to request.targetLanguage, - "text" to chunk + "text" to text ) val requestEntity = HttpEntity(body, headers) @@ -58,13 +55,8 @@ class PapagoTranslationService( } val translated = response.body?.message?.result?.translatedText - if (translated.isNullOrBlank()) { - repeat(textsInChunkCount) { translatedTexts.add("") } - } else { - translated.split(DELIMITER).forEach { translatedTexts.add(it) } - } + translatedTexts.add(translated ?: "") } catch (_: Exception) { - repeat(textsInChunkCount) { translatedTexts.add("") } } } @@ -83,26 +75,6 @@ class PapagoTranslationService( } } - 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(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", @@ -120,8 +92,5 @@ class PapagoTranslationService( "pt", "it" ) - - private const val DELIMITER = "\u001F\u001E\u001D\u001C\u001F\u001E\u001D\u001C" - private const val MAX_TEXT_LENGTH = 3000 } } -- 2.49.1 From 608898eb0c9467edb155ce00de93f9c358da1b9f Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 22:00:30 +0900 Subject: [PATCH 25/90] 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 5cddb914..fa1544bf 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 4b64d8aa..b86e159a 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 05117302..69f83294 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 00000000..df4cd988 --- /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 00000000..e197d1ea --- /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? +} -- 2.49.1 From 25169aaac3c6567fc8e205ea0e58270db9020397 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 22:14:18 +0900 Subject: [PATCH 26/90] =?UTF-8?q?getDetail=EC=97=90=20@Transactional=20?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/vividnext/sodalive/content/AudioContentService.kt | 1 + 1 file changed, 1 insertion(+) 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 b86e159a..4e851ad1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -503,6 +503,7 @@ class AudioContentService( } } + @Transactional fun getDetail( id: Long, member: Member, -- 2.49.1 From 28fbdd7826ea84d7d2e4daf6c91c95013b30d448 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 22:33:26 +0900 Subject: [PATCH 27/90] =?UTF-8?q?getDetail=EC=97=90=20languageCode?= =?UTF-8?q?=EB=A5=BC=20optional=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98?= =?UTF-8?q?=EC=97=AC=20languageCode=EA=B0=80=20=EC=97=86=EC=96=B4=EB=8F=84?= =?UTF-8?q?=20=EC=A0=95=EC=83=81=20=EC=A1=B0=ED=9A=8C=20=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/content/AudioContentController.kt | 4 ++-- .../kr/co/vividnext/sodalive/content/AudioContentService.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 fa1544bf..95b84e31 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -131,7 +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(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { @@ -143,7 +143,7 @@ class AudioContentController(private val service: AudioContentService) { member = member, isAdultContentVisible = isAdultContentVisible ?: true, timezone = timezone, - languageCode = languageCode ?: "ko" + languageCode = languageCode ) ) } 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 4e851ad1..c1d47b1b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -509,7 +509,7 @@ class AudioContentService( member: Member, isAdultContentVisible: Boolean, timezone: String, - languageCode: String + languageCode: String? ): GetAudioContentDetailResponse { val isAdult = member.auth != null && isAdultContentVisible @@ -746,7 +746,7 @@ class AudioContentService( if ( audioContent.languageCode != null && audioContent.languageCode!!.isNotBlank() && - languageCode.isNotBlank() && + !languageCode.isNullOrBlank() && audioContent.languageCode != languageCode ) { val locale = languageCode.lowercase() -- 2.49.1 From 143ba2fbb218de88fb24459bb9b533a7566eaca0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 11 Dec 2025 23:58:17 +0900 Subject: [PATCH 28/90] =?UTF-8?q?HomeApi=20-=20languageCode=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EC=BD=98=ED=85=90=EC=B8=A0,=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=EC=9D=98=20=EB=B2=88=EC=97=AD=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=A0=9C=EA=B3=B5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeController.kt | 8 +- .../sodalive/api/home/HomeService.kt | 287 +++++++++++++++++- .../AiCharacterTranslationRepository.kt | 2 + .../ContentTranslationRepository.kt | 2 + 4 files changed, 288 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt index 986a35b7..925c10ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt @@ -17,6 +17,7 @@ class HomeController(private val service: HomeService) { @GetMapping fun fetchData( @RequestParam timezone: String, + @RequestParam(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @@ -24,6 +25,7 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.fetchData( timezone = timezone, + languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, member @@ -34,6 +36,7 @@ class HomeController(private val service: HomeService) { @GetMapping("/latest-content") fun getLatestContentByTheme( @RequestParam("theme") theme: String, + @RequestParam(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @@ -41,6 +44,7 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.getLatestContentByTheme( theme = theme, + languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, member @@ -70,13 +74,15 @@ class HomeController(private val service: HomeService) { fun getRecommendContents( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { ApiResponse.ok( service.getRecommendContentList( isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, - member = member + member = member, + languageCode = languageCode ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index d66d1f8f..70ad6cb6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.api.home import kr.co.vividnext.sodalive.audition.AuditionService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.ContentType @@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationServic import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository @@ -47,6 +49,9 @@ class HomeService( private val rankingRepository: RankingRepository, private val explorerQueryRepository: ExplorerQueryRepository, + private val contentTranslationRepository: ContentTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -57,6 +62,7 @@ class HomeService( fun fetchData( timezone: String, + languageCode: String?, isAdultContentVisible: Boolean, contentType: ContentType, member: Member? @@ -111,6 +117,37 @@ class HomeService( } } + /** + * latestContentList 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 latestContentList의 title을 번역 데이터로 변경한다 + */ + val translatedLatestContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = latestContentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + latestContentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + latestContentList + } + } else { + latestContentList + } + val eventBannerList = GetEventResponse( totalCount = 0, eventList = emptyList() @@ -140,6 +177,38 @@ class HomeService( // 인기 캐릭터 조회 val popularCharacters = characterService.getPopularCharacters() + /** + * popularCharacters 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름을 번역 데이터로 변경한다 + */ + val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) { + val characterIds = popularCharacters.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + popularCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + popularCharacters + } + } else { + popularCharacters + } + val currentDateTime = LocalDateTime.now() val startDate = currentDateTime .withHour(15) @@ -159,12 +228,81 @@ class HomeService( sort = ContentRankingSortType.REVENUE ) + /** + * contentRanking 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 contentRanking title을 번역 데이터로 변경한다 + */ + val translatedContentRanking = if (!languageCode.isNullOrBlank()) { + val contentIds = contentRanking.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + contentRanking.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentRanking + } + } else { + contentRanking + } + val recommendChannelList = recommendChannelService.getRecommendChannel( memberId = memberId, isAdult = isAdult, contentType = contentType ) + /** + * recommendChannelList의 콘텐츠 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다 + */ + val translatedRecommendChannelList = if (!languageCode.isNullOrBlank()) { + val contentIds = recommendChannelList + .flatMap { it.contentList } + .map { it.contentId } + .distinct() + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + recommendChannelList.map { channel -> + val translatedContentList = channel.contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + + channel.copy(contentList = translatedContentList) + } + } else { + recommendChannelList + } + } else { + recommendChannelList + } + val freeContentList = contentService.getLatestContentByTheme( theme = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, @@ -183,6 +321,37 @@ class HomeService( } } + /** + * freeContentList 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 freeContentList title을 번역 데이터로 변경한다 + */ + val translatedFreeContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = freeContentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + freeContentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + freeContentList + } + } else { + freeContentList + } + // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = contentService.getLatestContentByTheme( theme = emptyList(), @@ -199,6 +368,37 @@ class HomeService( } } + /** + * pointAvailableContentList 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 pointAvailableContentList title을 번역 데이터로 변경한다 + */ + val translatedPointAvailableContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = pointAvailableContentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + pointAvailableContentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + pointAvailableContentList + } + } else { + pointAvailableContentList + } + val curationList = curationService.getContentCurationList( tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 isAdult = isAdult, @@ -210,21 +410,22 @@ class HomeService( liveList = liveList, creatorRanking = creatorRanking, latestContentThemeList = latestContentThemeList, - latestContentList = latestContentList, + latestContentList = translatedLatestContentList, bannerList = bannerList, eventBannerList = eventBannerList, originalAudioDramaList = originalAudioDramaList, auditionList = auditionList, dayOfWeekSeriesList = dayOfWeekSeriesList, - popularCharacters = popularCharacters, - contentRanking = contentRanking, - recommendChannelList = recommendChannelList, - freeContentList = freeContentList, - pointAvailableContentList = pointAvailableContentList, + popularCharacters = translatedPopularCharacters, + contentRanking = translatedContentRanking, + recommendChannelList = translatedRecommendChannelList, + freeContentList = translatedFreeContentList, + pointAvailableContentList = translatedPointAvailableContentList, recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, - member = member + member = member, + languageCode = languageCode ), curationList = curationList ) @@ -232,6 +433,7 @@ class HomeService( fun getLatestContentByTheme( theme: String, + languageCode: String?, isAdultContentVisible: Boolean, contentType: ContentType, member: Member? @@ -249,7 +451,7 @@ class HomeService( listOf(theme) } - return contentService.getLatestContentByTheme( + val contentList = contentService.getLatestContentByTheme( theme = themeList, contentType = contentType, isFree = false, @@ -261,6 +463,39 @@ class HomeService( true } } + + /** + * contentList 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 contentList title을 번역 데이터로 변경한다 + */ + val translatedContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = contentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentList + } + } else { + contentList + } + + return translatedContentList } fun getDayOfWeekSeriesList( @@ -336,7 +571,8 @@ class HomeService( fun getRecommendContentList( isAdultContentVisible: Boolean, contentType: ContentType, - member: Member? + member: Member?, + languageCode: String? = null ): List { val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible @@ -371,6 +607,37 @@ class HomeService( } } - return result + /** + * 추천 콘텐츠 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 result의 title을 번역 데이터로 변경한다 + */ + val translatedResult = if (!languageCode.isNullOrBlank()) { + val contentIds = result.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + result.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + result + } + } else { + result + } + + return translatedResult } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt index 430f253b..112998b3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface AiCharacterTranslationRepository : JpaRepository { fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation? + + fun findByCharacterIdInAndLocale(characterIds: List, locale: String): List } 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 index e197d1ea..9e59a80a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface ContentTranslationRepository : JpaRepository { fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation? + + fun findByContentIdInAndLocale(contentIds: List, locale: String): List } -- 2.49.1 From 5bdb6d20a5d3f46df91548e32cc5dae6d0379120 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 01:00:41 +0900 Subject: [PATCH 29/90] =?UTF-8?q?=EB=B2=88=EC=97=AD=20-=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EC=9D=B4=EB=A9=B4=20API=EB=A5=BC=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EB=B9=88=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/PapagoTranslationService.kt | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) 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 index 6fb31fea..878bc51a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -24,7 +24,9 @@ class PapagoTranslationService( return TranslateResult(emptyList()) } - validateLanguages(request.sourceLanguage, request.targetLanguage) + if (!validateLanguages(request.sourceLanguage, request.targetLanguage)) { + return TranslateResult(emptyList()) + } val headers = HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON @@ -63,34 +65,20 @@ class PapagoTranslationService( return TranslateResult(translatedTexts) } - private fun validateLanguages(sourceLanguage: String, targetLanguage: String) { - requireSupportedLanguage(sourceLanguage) - requireSupportedLanguage(targetLanguage) + private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean { + return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage) } - private fun requireSupportedLanguage(language: String) { + private fun requireSupportedLanguage(language: String): Boolean { val normalized = language.lowercase() - if (!SUPPORTED_LANGUAGE_CODES.contains(normalized)) { - throw IllegalArgumentException("지원하지 않는 언어 코드입니다: $language") - } + return SUPPORTED_LANGUAGE_CODES.contains(normalized) } companion object { private val SUPPORTED_LANGUAGE_CODES = setOf( "ko", "en", - "ja", - "zh-cn", - "zh-tw", - "es", - "fr", - "vi", - "th", - "id", - "de", - "ru", - "pt", - "it" + "ja" ) } } -- 2.49.1 From 2355aa7c7528c5d65ff132414b08c511377c371f Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 01:32:02 +0900 Subject: [PATCH 30/90] =?UTF-8?q?AI=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=9C=EA=B3=B5=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 --- .../controller/ChatCharacterController.kt | 263 +++++++++++++++++- 1 file changed, 251 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 2f1dfa69..2f3ff8eb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse import kr.co.vividnext.sodalive.chat.character.dto.CurationSection import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter +import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation @@ -33,6 +34,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import kotlin.collections.map @RestController @RequestMapping("/api/chat/character") @@ -51,6 +53,7 @@ class ChatCharacterController( ) { @GetMapping("/main") fun getCharacterMain( + @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse = run { // 배너 조회 (최대 10개) @@ -77,15 +80,110 @@ class ChatCharacterController( } } + /** + * 최근 대화한 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 recentCharacters 캐릭터 이름을 번역 데이터로 변경한다 + */ + val translatedRecentCharacters = if (!languageCode.isNullOrBlank()) { + val characterIds = recentCharacters.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + recentCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + if (translatedName.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName) + } + } + } else { + recentCharacters + } + } else { + recentCharacters + } + // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() + /** + * popularCharacters 캐릭터 이름과 description 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름과 description을 번역 데이터로 변경한다 + */ + val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) { + val characterIds = popularCharacters.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + popularCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + popularCharacters + } + } else { + popularCharacters + } + // 최근 등록된 캐릭터 리스트 조회 val newCharacters = service.getRecentCharactersPage( page = 0, size = 50 ).content + /** + * 최근 등록된 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 newCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다 + */ + val translatedNewCharacters = if (!languageCode.isNullOrBlank()) { + val characterIds = newCharacters.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + newCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + newCharacters + } + } else { + newCharacters + } + // 추천 캐릭터 조회 // 최근 대화한 캐릭터를 제외한 랜덤 30개 조회 // Controller에서는 호출만 @@ -93,6 +191,38 @@ class ChatCharacterController( val excludeIds = recentCharacters.map { it.characterId } val recommendCharacters = service.getRecommendCharacters(excludeIds, 30) + /** + * 추천 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 recommendCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다 + */ + val translatedRecommendCharacters = if (!languageCode.isNullOrBlank()) { + val characterIds = recommendCharacters.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + recommendCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + recommendCharacters + } + } else { + recommendCharacters + } + // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() .map { agg -> @@ -115,10 +245,10 @@ class ChatCharacterController( ApiResponse.ok( CharacterMainResponse( banners = banners, - recentCharacters = recentCharacters, - popularCharacters = popularCharacters, - newCharacters = newCharacters, - recommendCharacters = recommendCharacters, + recentCharacters = translatedRecentCharacters, + popularCharacters = translatedPopularCharacters, + newCharacters = translatedNewCharacters, + recommendCharacters = translatedRecommendCharacters, curationSections = curationSections ) ) @@ -291,6 +421,40 @@ class ChatCharacterController( ) } + /** + * 다른 캐릭터 이름, 태그 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다 + */ + val translatedOthers = if (!languageCode.isNullOrBlank()) { + val characterIds = others.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + others.map { other -> + val payload = translations[other.characterId]?.renderedPayload + val translatedName = payload?.name + val translatedTags = payload?.tags + + if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) { + other + } else { + other.copy(name = translatedName, tags = translatedTags) + } + } + } else { + others + } + } else { + others + } + // 최신 댓글 1개 조회 val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!) @@ -311,7 +475,7 @@ class ChatCharacterController( originalTitle = character.originalTitle, originalLink = character.originalLink, characterType = character.characterType, - others = others, + others = translatedOthers, latestComment = latestComment, totalComments = characterCommentService.getTotalCommentCount(character.id!!), translated = translated @@ -325,13 +489,53 @@ class ChatCharacterController( * - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공 */ @GetMapping("/recent") - fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run { - ApiResponse.ok( - service.getRecentCharactersPage( - page = page ?: 0, - size = 20 - ) + fun getRecentCharacters( + @RequestParam(required = false) languageCode: String? = null, + @RequestParam("page", required = false) page: Int? + ): ApiResponse = run { + val characterPage = service.getRecentCharactersPage( + page = page ?: 0, + size = 20 ) + + /** + * 최근 등록된 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다 + */ + val translatedContent = if (!languageCode.isNullOrBlank()) { + val characterIds = characterPage.content.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + characterPage.content.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + characterPage.content + } + } else { + characterPage.content + } + + val translatedCharacterPage = RecentCharactersResponse( + totalCount = characterPage.totalCount, + content = translatedContent + ) + + ApiResponse.ok(translatedCharacterPage) } /** @@ -341,6 +545,7 @@ class ChatCharacterController( */ @GetMapping("/recommend") fun getRecommendCharacters( + @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { val recent = if (member == null || member.auth == null) { @@ -350,6 +555,40 @@ class ChatCharacterController( .listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려 .map { it.characterId } } - ApiResponse.ok(service.getRecommendCharacters(recent, 20)) + val characterList = service.getRecommendCharacters(recent, 20) + + /** + * 추천 캐릭터 이름 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다 + */ + val translatedCharacterList = if (!languageCode.isNullOrBlank()) { + val characterIds = characterList.map { it.characterId } + + if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) + .associateBy { it.characterId } + + characterList.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + characterList + } + } else { + characterList + } + + ApiResponse.ok(translatedCharacterList) } } -- 2.49.1 From 5d925e98e0a077a9e428ddeda729a880b371857c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 03:05:50 +0900 Subject: [PATCH 31/90] =?UTF-8?q?AI=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4=ED=84=B0,=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/controller/ChatCharacterController.kt | 2 -- .../sodalive/chat/character/translate/AiCharacterTranslation.kt | 2 -- .../kr/co/vividnext/sodalive/content/AudioContentService.kt | 1 - .../sodalive/content/translation/ContentTranslation.kt | 1 - 4 files changed, 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 2f3ff8eb..68a292f0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -388,8 +388,6 @@ class ChatCharacterController( val entity = AiCharacterTranslation( characterId = character.id!!, locale = locale, - translatedName = translatedName, - translatedTags = translatedTags, renderedPayload = payload ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt index 71725c55..8d05dfb2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -20,8 +20,6 @@ import javax.persistence.UniqueConstraint class AiCharacterTranslation( val characterId: Long, val locale: String, - val translatedName: String, - val translatedTags: String, @Column(columnDefinition = "json") @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) 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 c1d47b1b..4dfedb58 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -795,7 +795,6 @@ class AudioContentService( kr.co.vividnext.sodalive.content.translation.ContentTranslation( contentId = audioContent.id!!, locale = locale, - translatedTitle = translatedTitle, renderedPayload = payload ) ) 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 index df4cd988..66f95795 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt @@ -20,7 +20,6 @@ import javax.persistence.UniqueConstraint class ContentTranslation( val contentId: Long, val locale: String, - val translatedTitle: String, @Column(columnDefinition = "json") @Convert(converter = ContentTranslationPayloadConverter::class) -- 2.49.1 From 8fec60db11aad341d7f7984b600e900068e62a9f Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 04:52:02 +0900 Subject: [PATCH 32/90] =?UTF-8?q?AI=20=EC=BA=90=EB=A6=AD=ED=84=B0,=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character/AdminChatCharacterController.kt | 9 + .../translate/AiCharacterTranslation.kt | 2 +- .../sodalive/content/AudioContentService.kt | 22 +- .../sodalive/content/LanguageDetectEvent.kt | 19 ++ .../content/translation/ContentTranslation.kt | 2 +- .../translation/LanguageTranslationEvent.kt | 205 ++++++++++++++++++ .../translation/PapagoTranslationService.kt | 11 + 7 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 21a7bda1..85c42988 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.LanguageDetectEvent import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -331,6 +333,13 @@ class AdminChatCharacterController( request = request ) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.id, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 if (request.originalWorkId != null) { // 서비스에서 유효성 검증 및 저장까지 처리 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt index 8d05dfb2..3b1e7fc2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -23,7 +23,7 @@ class AiCharacterTranslation( @Column(columnDefinition = "json") @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) - val renderedPayload: AiCharacterTranslationRenderedPayload + var renderedPayload: AiCharacterTranslationRenderedPayload ) : BaseEntity() data class AiCharacterTranslationRenderedPayload( 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 4dfedb58..54a05339 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -21,12 +21,16 @@ 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.ContentTranslation +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload 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.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member @@ -167,6 +171,13 @@ class AudioContentService( audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) } + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) } @Transactional @@ -357,6 +368,13 @@ class AudioContentService( ) } + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = audioContent.id!!, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + return CreateAudioContentResponse(contentId = audioContent.id!!) } @@ -785,14 +803,14 @@ class AudioContentService( val translatedDetail = translatedTexts[index++] val translatedTags = translatedTexts[index] - val payload = kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload( + val payload = ContentTranslationPayload( title = translatedTitle, detail = translatedDetail, tags = translatedTags ) contentTranslationRepository.save( - kr.co.vividnext.sodalive.content.translation.ContentTranslation( + ContentTranslation( contentId = audioContent.id!!, locale = locale, renderedPayload = payload diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 64a3bfb2..fc552f61 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepositor import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -47,6 +50,8 @@ class LanguageDetectListener( private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.naver.papago-client-id}") private val papagoClientId: String, @@ -102,6 +107,13 @@ class LanguageDetectListener( character.languageCode = langCode chatCharacterRepository.save(character) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = characterId, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + log.info( "[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}", characterId, @@ -135,6 +147,13 @@ class LanguageDetectListener( // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. audioContentRepository.save(audioContent) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + log.info( "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", contentId, 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 index 66f95795..75d7a6b5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt @@ -23,7 +23,7 @@ class ContentTranslation( @Column(columnDefinition = "json") @Convert(converter = ContentTranslationPayloadConverter::class) - val renderedPayload: ContentTranslationPayload + var renderedPayload: ContentTranslationPayload ) : BaseEntity() data class ContentTranslationPayload( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt new file mode 100644 index 00000000..f0ba1cf0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.i18n.translation + +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground +import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality +import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.translation.ContentTranslation +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes +import org.springframework.data.repository.findByIdOrNull +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 + +enum class LanguageTranslationTargetType { + CONTENT, + CHARACTER +} + +class LanguageTranslationEvent( + val id: Long, + val targetType: LanguageTranslationTargetType +) + +@Component +class LanguageTranslationListener( + private val audioContentRepository: AudioContentRepository, + private val chatCharacterRepository: ChatCharacterRepository, + + private val contentTranslationRepository: ContentTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + + private val translationService: PapagoTranslationService +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun translation(event: LanguageTranslationEvent) { + when (event.targetType) { + LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) + LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) + } + } + + private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) { + val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return + val languageCode = audioContent.languageCode + if (languageCode != null) return + + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val tags = audioContent.audioContentHashTags + .mapNotNull { it.hashTag?.tag } + .joinToString(",") + + val texts = mutableListOf() + texts.add(audioContent.title) + texts.add(audioContent.detail) + texts.add(tags) + + 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 = ContentTranslationPayload( + title = translatedTitle, + detail = translatedDetail, + tags = translatedTags + ) + + val existing = contentTranslationRepository + .findByContentIdAndLocale(audioContent.id!!, locale) + + if (existing == null) { + contentTranslationRepository.save( + ContentTranslation( + contentId = audioContent.id!!, + locale = locale, + renderedPayload = payload + ) + ) + } else { + existing.renderedPayload = payload + contentTranslationRepository.save(existing) + } + } + } + } + + private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) { + val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return + val languageCode = character.languageCode + if (languageCode != null) return + + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val personality = character.personalities.firstOrNull() + val background = character.backgrounds.firstOrNull() + + val tags = character.tagMappings.joinToString(",") { it.tag.tag } + + val texts = mutableListOf() + texts.add(character.name) + texts.add(character.description) + texts.add(character.gender ?: "") + + val hasPersonality = personality != null + if (hasPersonality) { + texts.add(personality!!.trait) + texts.add(personality.description) + } + + val hasBackground = background != null + if (hasBackground) { + texts.add(background!!.topic) + texts.add(background.description) + } + + texts.add(tags) + + val sourceLanguage = character.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 translatedName = translatedTexts[index++] + val translatedDescription = translatedTexts[index++] + val translatedGender = translatedTexts[index++] + + var translatedPersonality: TranslatedAiCharacterPersonality? = null + if (hasPersonality) { + translatedPersonality = TranslatedAiCharacterPersonality( + trait = translatedTexts[index++], + description = translatedTexts[index++] + ) + } + + var translatedBackground: TranslatedAiCharacterBackground? = null + if (hasBackground) { + translatedBackground = TranslatedAiCharacterBackground( + topic = translatedTexts[index++], + description = translatedTexts[index++] + ) + } + + val translatedTags = translatedTexts[index] + + val payload = AiCharacterTranslationRenderedPayload( + name = translatedName, + description = translatedDescription, + gender = translatedGender, + personalityTrait = translatedPersonality?.trait ?: "", + personalityDescription = translatedPersonality?.description ?: "", + backgroundTopic = translatedBackground?.topic ?: "", + backgroundDescription = translatedBackground?.description ?: "", + tags = translatedTags + ) + + val existing = aiCharacterTranslationRepository + .findByCharacterIdAndLocale(character.id!!, locale) + + if (existing == null) { + val entity = AiCharacterTranslation( + characterId = character.id!!, + locale = locale, + renderedPayload = payload + ) + + aiCharacterTranslationRepository.save(entity) + } else { + existing.renderedPayload = payload + aiCharacterTranslationRepository.save(existing) + } + } + } + } +} 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 index 878bc51a..29a76b5d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -80,5 +80,16 @@ class PapagoTranslationService( "en", "ja" ) + + /** + * 번역 대상 언어 코드 집합을 반환한다. + * + * @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시) + * @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합 + */ + fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set { + val normalized = excludedLanguageCode?.lowercase() + return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet() + } } } -- 2.49.1 From 7ab25470b6fd7670df4d3d92a78c191b76efd81d Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 05:57:04 +0900 Subject: [PATCH 33/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=B3=B4=EA=B8=B0=20API=20-=20languageCode=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentController.kt | 6 ++- .../sodalive/content/AudioContentService.kt | 54 +++++++++++++++++-- .../main/AudioContentMainController.kt | 2 + .../content/main/AudioContentMainService.kt | 31 ++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) 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 95b84e31..9c1f24fb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -108,6 +108,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestParam("category-id", required = false) categoryId: Long? = 0, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, + @RequestParam("languageCode", required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { @@ -120,6 +121,7 @@ class AudioContentController(private val service: AudioContentService) { categoryId = categoryId ?: 0, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, + languageCode = languageCode, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -241,6 +243,7 @@ class AudioContentController(private val service: AudioContentService) { @GetMapping("/all") fun getAllContents( + @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("isFree", required = false) isFree: Boolean? = null, @@ -261,7 +264,8 @@ class AudioContentController(private val service: AudioContentService) { sortType = sortType ?: SortType.NEWEST, isFree = isFree ?: false, isAdult = (isAdultContentVisible ?: true) && member.auth != null, - isPointAvailableOnly = isPointAvailableOnly ?: false + isPointAvailableOnly = isPointAvailableOnly ?: false, + languageCode = languageCode ) ) } 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 54a05339..f7183192 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -928,6 +928,7 @@ class AudioContentService( categoryId: Long = 0, isAdultContentVisible: Boolean, contentType: ContentType, + languageCode: String?, offset: Long, limit: Long ): GetAudioContentListResponse { @@ -981,9 +982,32 @@ class AudioContentService( it } + val translatedContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = items.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + items.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + items + } + } else { + items + } + return GetAudioContentListResponse( totalCount = totalCount, - items = items + items = translatedContentList ) } @@ -1121,9 +1145,10 @@ class AudioContentService( isFree: Boolean = false, isAdult: Boolean = false, orderByRandom: Boolean = false, - isPointAvailableOnly: Boolean = false + isPointAvailableOnly: Boolean = false, + languageCode: String? = null ): List { - return repository.getLatestContentByTheme( + val contentList = repository.getLatestContentByTheme( theme = theme, contentType = contentType, offset = offset, @@ -1134,5 +1159,28 @@ class AudioContentService( orderByRandom = orderByRandom, isPointAvailableOnly = isPointAvailableOnly ) + + return if (!languageCode.isNullOrBlank()) { + val contentIds = contentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentList + } + } else { + contentList + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt index c534be5d..892306cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -100,6 +100,7 @@ class AudioContentMainController( @GetMapping("/new/all") fun getNewContentAllByTheme( @RequestParam("theme") theme: String, + @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @@ -112,6 +113,7 @@ class AudioContentMainController( theme = theme, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, + languageCode = languageCode, member = member, pageable = pageable ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index 72a025eb..addeb8e4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -21,6 +22,8 @@ class AudioContentMainService( private val blockMemberRepository: BlockMemberRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository, + private val contentTranslationRepository: ContentTranslationRepository, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -57,6 +60,7 @@ class AudioContentMainService( theme: String, isAdultContentVisible: Boolean, contentType: ContentType, + languageCode: String?, member: Member, pageable: Pageable ): GetNewContentAllResponse { @@ -76,7 +80,7 @@ class AudioContentMainService( isAdult = isAdult, contentType = contentType ) - val items = repository.findByThemeFor2Weeks( + val contentList = repository.findByThemeFor2Weeks( cloudfrontHost = imageHost, memberId = member.id!!, theme = themeList, @@ -87,7 +91,30 @@ class AudioContentMainService( ) .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } - return GetNewContentAllResponse(totalCount, items) + val translatedContentList = if (!languageCode.isNullOrBlank()) { + val contentIds = contentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentList + } + } else { + contentList + } + + return GetNewContentAllResponse(totalCount, translatedContentList) } @Transactional(readOnly = true) -- 2.49.1 From 236394e148e16ad2bde4dd8d2bcba1a78d27a63c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 06:04:26 +0900 Subject: [PATCH 34/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=B3=B4=EA=B8=B0=20API=20-=20languageCode=EC=97=90?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 759c83f9..4ca9dc06 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -224,6 +224,7 @@ class ExplorerService( sortType = SortType.NEWEST, isAdultContentVisible = isAdultContentVisible, contentType = ContentType.ALL, + languageCode = null, member = member, offset = 0, limit = 3 -- 2.49.1 From 04281817a5237a8ad517d793cdd1cf6356dd2906 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 13:58:49 +0900 Subject: [PATCH 35/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B1=84=EB=84=90=20-=20languageCode=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=B2=88?= =?UTF-8?q?=EC=97=AD=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/explorer/ExplorerController.kt | 2 ++ .../sodalive/explorer/ExplorerService.kt | 28 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index b47792a5..bf367df8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -52,6 +52,7 @@ class ExplorerController(private val service: ExplorerService) { fun getCreatorProfile( @PathVariable("id") creatorId: Long, @RequestParam timezone: String, + @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { @@ -60,6 +61,7 @@ class ExplorerController(private val service: ExplorerService) { service.getCreatorProfile( creatorId = creatorId, timezone = timezone, + languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, member = member ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 4ca9dc06..73dd9e38 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.content.LanguageDetectEvent import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.series.ContentSeriesService +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice @@ -46,6 +47,7 @@ class ExplorerService( private val seriesService: ContentSeriesService, private val applicationEventPublisher: ApplicationEventPublisher, + private val contentTranslationRepository: ContentTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -170,6 +172,7 @@ class ExplorerService( fun getCreatorProfile( creatorId: Long, timezone: String, + languageCode: String?, isAdultContentVisible: Boolean, member: Member ): GetCreatorProfileResponse { @@ -233,6 +236,29 @@ class ExplorerService( listOf() } + val translatedContentList = if (!languageCode.isNullOrBlank() && contentList.isNotEmpty()) { + val contentIds = contentList.map { it.contentId } + + if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentList + } + } else { + contentList + } + // 크리에이터의 최신 오디오 콘텐츠 1개 val latestContent = if (isCreator) { audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible) @@ -333,7 +359,7 @@ class ExplorerService( userDonationRanking = memberDonationRanking, similarCreatorList = similarCreatorList, liveRoomList = liveRoomList, - contentList = contentList, + contentList = translatedContentList, latestContent = latestContent, totalContentCount = totalContentCount, ownedContentCount = ownedContentCount, -- 2.49.1 From 082f255773463a31f506186fe53ebaf9d5a3ae0d Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 16:57:34 +0900 Subject: [PATCH 36/90] =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=94=84=20=EC=96=B8=EC=96=B4=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interceptor에서 Accept-Language 헤더를 파싱 - 요청 단위 LangContext에 언어 정보 저장 - 서비스 및 예외 처리 계층에서 언어 컨텍스트 주입 - enum 및 when 기반 언어 정책을 한 곳으로 통합 --- .../vividnext/sodalive/configs/WebConfig.kt | 10 ++++++++- .../kr/co/vividnext/sodalive/i18n/Lang.kt | 22 +++++++++++++++++++ .../co/vividnext/sodalive/i18n/LangContext.kt | 15 +++++++++++++ .../sodalive/i18n/LangInterceptor.kt | 21 ++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt index 6c38c9c8..6ef72f95 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt @@ -1,11 +1,19 @@ package kr.co.vividnext.sodalive.configs +import kr.co.vividnext.sodalive.i18n.LangInterceptor import org.springframework.context.annotation.Configuration import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration -class WebConfig : WebMvcConfigurer { +class WebConfig( + private val langInterceptor: LangInterceptor +) : WebMvcConfigurer { + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(langInterceptor).addPathPatterns("/**") + } + override fun addCorsMappings(registry: CorsRegistry) { registry.addMapping("/**") .allowedOrigins( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt new file mode 100644 index 00000000..d40abe32 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.i18n + +import java.util.Locale + +enum class Lang(val code: String, val locale: Locale) { + KO("ko", Locale.KOREAN), + EN("en", Locale.ENGLISH), + JA("ja", Locale.JAPANESE); + + companion object { + fun fromAcceptLanguage(header: String?): Lang { + if (header.isNullOrBlank()) return KO + val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리 + return when (two) { + "ko" -> KO + "en" -> EN + "ja" -> JA + else -> KO + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt new file mode 100644 index 00000000..61bd91bf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.i18n + +import org.springframework.stereotype.Component +import org.springframework.web.context.annotation.RequestScope + +@Component +@RequestScope +class LangContext { + var lang: Lang = Lang.KO + internal set + + fun setLang(lang: Lang) { + this.lang = lang + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt new file mode 100644 index 00000000..8e063299 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.i18n + +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class LangInterceptor( + private val langContext: LangContext +) : HandlerInterceptor { + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any + ): Boolean { + val acceptLanguage = request.getHeader("Accept-Language") + langContext.setLang(Lang.fromAcceptLanguage(acceptLanguage)) + return true + } +} -- 2.49.1 From ba1844a6c2a4216061a4debfa0365ae5124ddbfe Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 17:22:50 +0900 Subject: [PATCH 37/90] =?UTF-8?q?Home=20API=EC=97=90=EC=84=9C=20api=20?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20languageCode=EB=A5=BC=20=EB=B3=84=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EB=8D=98=20=EA=B2=83=EC=9D=84=20LangConte?= =?UTF-8?q?xt=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeController.kt | 8 +- .../sodalive/api/home/HomeService.kt | 334 ++++++------------ 2 files changed, 104 insertions(+), 238 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt index 925c10ba..986a35b7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeController.kt @@ -17,7 +17,6 @@ class HomeController(private val service: HomeService) { @GetMapping fun fetchData( @RequestParam timezone: String, - @RequestParam(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @@ -25,7 +24,6 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.fetchData( timezone = timezone, - languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, member @@ -36,7 +34,6 @@ class HomeController(private val service: HomeService) { @GetMapping("/latest-content") fun getLatestContentByTheme( @RequestParam("theme") theme: String, - @RequestParam(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @@ -44,7 +41,6 @@ class HomeController(private val service: HomeService) { ApiResponse.ok( service.getLatestContentByTheme( theme = theme, - languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, member @@ -74,15 +70,13 @@ class HomeController(private val service: HomeService) { fun getRecommendContents( @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, - @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { ApiResponse.ok( service.getRecommendContentList( isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, - member = member, - languageCode = languageCode + member = member ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 70ad6cb6..7e2581ba 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.api.home import kr.co.vividnext.sodalive.audition.AuditionService +import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.content.AudioContentMainItem @@ -16,6 +17,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.room.LiveRoomService import kr.co.vividnext.sodalive.live.room.LiveRoomStatus import kr.co.vividnext.sodalive.member.Member @@ -52,6 +54,8 @@ class HomeService( private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -62,7 +66,6 @@ class HomeService( fun fetchData( timezone: String, - languageCode: String?, isAdultContentVisible: Boolean, contentType: ContentType, member: Member? @@ -117,36 +120,7 @@ class HomeService( } } - /** - * latestContentList 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 latestContentList의 title을 번역 데이터로 변경한다 - */ - val translatedLatestContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = latestContentList.map { it.contentId } - - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - latestContentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - latestContentList - } - } else { - latestContentList - } + val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList) val eventBannerList = GetEventResponse( totalCount = 0, @@ -175,39 +149,7 @@ class HomeService( ) // 인기 캐릭터 조회 - val popularCharacters = characterService.getPopularCharacters() - - /** - * popularCharacters 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름을 번역 데이터로 변경한다 - */ - val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) { - val characterIds = popularCharacters.map { it.characterId } - - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - popularCharacters.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - popularCharacters - } - } else { - popularCharacters - } + val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) val currentDateTime = LocalDateTime.now() val startDate = currentDateTime @@ -228,32 +170,19 @@ class HomeService( sort = ContentRankingSortType.REVENUE ) - /** - * contentRanking 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 contentRanking title을 번역 데이터로 변경한다 - */ - val translatedContentRanking = if (!languageCode.isNullOrBlank()) { - val contentIds = contentRanking.map { it.contentId } + val contentRankingContentIds = contentRanking.map { it.contentId } + val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - contentRanking.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + contentRanking.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - contentRanking } } else { contentRanking @@ -273,31 +202,27 @@ class HomeService( * * 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다 */ - val translatedRecommendChannelList = if (!languageCode.isNullOrBlank()) { - val contentIds = recommendChannelList - .flatMap { it.contentList } - .map { it.contentId } - .distinct() + val channelContentIds = recommendChannelList + .flatMap { it.contentList } + .map { it.contentId } + .distinct() - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } + val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - recommendChannelList.map { channel -> - val translatedContentList = channel.contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + recommendChannelList.map { channel -> + val translatedContentList = channel.contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - - channel.copy(contentList = translatedContentList) } - } else { - recommendChannelList + + channel.copy(contentList = translatedContentList) } } else { recommendChannelList @@ -321,36 +246,7 @@ class HomeService( } } - /** - * freeContentList 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 freeContentList title을 번역 데이터로 변경한다 - */ - val translatedFreeContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = freeContentList.map { it.contentId } - - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - freeContentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - freeContentList - } - } else { - freeContentList - } + val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = contentService.getLatestContentByTheme( @@ -368,36 +264,7 @@ class HomeService( } } - /** - * pointAvailableContentList 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 pointAvailableContentList title을 번역 데이터로 변경한다 - */ - val translatedPointAvailableContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = pointAvailableContentList.map { it.contentId } - - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - pointAvailableContentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - pointAvailableContentList - } - } else { - pointAvailableContentList - } + val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) val curationList = curationService.getContentCurationList( tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 @@ -424,8 +291,7 @@ class HomeService( recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, - member = member, - languageCode = languageCode + member = member ), curationList = curationList ) @@ -433,7 +299,6 @@ class HomeService( fun getLatestContentByTheme( theme: String, - languageCode: String?, isAdultContentVisible: Boolean, contentType: ContentType, member: Member? @@ -464,38 +329,7 @@ class HomeService( } } - /** - * contentList 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 contentList title을 번역 데이터로 변경한다 - */ - val translatedContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = contentList.map { it.contentId } - - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } - } - } else { - contentList - } - } else { - contentList - } - - return translatedContentList + return getTranslatedContentList(contentList = contentList) } fun getDayOfWeekSeriesList( @@ -571,8 +405,7 @@ class HomeService( fun getRecommendContentList( isAdultContentVisible: Boolean, contentType: ContentType, - member: Member?, - languageCode: String? = null + member: Member? ): List { val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible @@ -607,37 +440,76 @@ class HomeService( } } - /** - * 추천 콘텐츠 번역 데이터 조회 - * - * languageCode != null - * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale - * - * 한 번에 조회하고 contentId를 매핑하여 result의 title을 번역 데이터로 변경한다 - */ - val translatedResult = if (!languageCode.isNullOrBlank()) { - val contentIds = result.map { it.contentId } + return getTranslatedContentList(contentList = result) + } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } + /** + * 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * contentTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + * + * @param contentList 번역 대상 AudioContentMainItem 목록 + * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 + */ + private fun getTranslatedContentList(contentList: List): List { + val contentIds = contentList.map { it.contentId } - result.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + return if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - result } } else { - result + contentList } + } - return translatedResult + /** + * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서 + * 번역 데이터를 한 번에 조회한다. + * - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만 + * 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다. + * + * @param aiCharacterList 번역 대상 캐릭터 목록 + * @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지 + */ + private fun getTranslatedAiCharacterList(aiCharacterList: List): List { + val characterIds = aiCharacterList.map { it.characterId } + + return if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .associateBy { it.characterId } + + aiCharacterList.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) + } + } + } else { + aiCharacterList + } } } -- 2.49.1 From 165640201fa0454012a9815e2b3b168fdc40a920 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 19:09:33 +0900 Subject: [PATCH 38/90] =?UTF-8?q?AI=20Character=20API=EC=97=90=EC=84=9C=20?= =?UTF-8?q?api=20=EB=A7=88=EB=8B=A4=20languageCode=EB=A5=BC=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=20=EB=B0=9B=EB=8D=98=20=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?LangContext=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 284 +++++------------- 1 file changed, 71 insertions(+), 213 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 68a292f0..63ae72be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPe import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member @@ -34,7 +35,6 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import kotlin.collections.map @RestController @RequestMapping("/api/chat/character") @@ -48,12 +48,13 @@ class ChatCharacterController( private val translationService: PapagoTranslationService, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @GetMapping("/main") fun getCharacterMain( - @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse = run { // 배너 조회 (최대 10개) @@ -80,32 +81,19 @@ class ChatCharacterController( } } - /** - * 최근 대화한 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 recentCharacters 캐릭터 이름을 번역 데이터로 변경한다 - */ - val translatedRecentCharacters = if (!languageCode.isNullOrBlank()) { - val characterIds = recentCharacters.map { it.characterId } + val characterIds = recentCharacters.map { it.characterId } + val translatedRecentCharacters = if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .associateBy { it.characterId } - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - recentCharacters.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - if (translatedName.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName) - } + recentCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + if (translatedName.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName) } - } else { - recentCharacters } } else { recentCharacters @@ -114,76 +102,12 @@ class ChatCharacterController( // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() - /** - * popularCharacters 캐릭터 이름과 description 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름과 description을 번역 데이터로 변경한다 - */ - val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) { - val characterIds = popularCharacters.map { it.characterId } - - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - popularCharacters.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - popularCharacters - } - } else { - popularCharacters - } - // 최근 등록된 캐릭터 리스트 조회 val newCharacters = service.getRecentCharactersPage( page = 0, size = 50 ).content - /** - * 최근 등록된 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 newCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다 - */ - val translatedNewCharacters = if (!languageCode.isNullOrBlank()) { - val characterIds = newCharacters.map { it.characterId } - - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - newCharacters.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - newCharacters - } - } else { - newCharacters - } - // 추천 캐릭터 조회 // 최근 대화한 캐릭터를 제외한 랜덤 30개 조회 // Controller에서는 호출만 @@ -191,38 +115,6 @@ class ChatCharacterController( val excludeIds = recentCharacters.map { it.characterId } val recommendCharacters = service.getRecommendCharacters(excludeIds, 30) - /** - * 추천 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 recommendCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다 - */ - val translatedRecommendCharacters = if (!languageCode.isNullOrBlank()) { - val characterIds = recommendCharacters.map { it.characterId } - - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - recommendCharacters.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - recommendCharacters - } - } else { - recommendCharacters - } - // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) val curationSections = curationQueryService.getActiveCurationsWithCharacters() .map { agg -> @@ -246,9 +138,9 @@ class ChatCharacterController( CharacterMainResponse( banners = banners, recentCharacters = translatedRecentCharacters, - popularCharacters = translatedPopularCharacters, - newCharacters = translatedNewCharacters, - recommendCharacters = translatedRecommendCharacters, + popularCharacters = getTranslatedAiCharacterList(popularCharacters), + newCharacters = getTranslatedAiCharacterList(newCharacters), + recommendCharacters = getTranslatedAiCharacterList(recommendCharacters), curationSections = curationSections ) ) @@ -261,7 +153,6 @@ class ChatCharacterController( @GetMapping("/{characterId}") fun getCharacterDetail( @PathVariable characterId: Long, - @RequestParam(required = false, defaultValue = "ko") languageCode: String? = "ko", @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") @@ -292,11 +183,9 @@ class ChatCharacterController( } var translated: TranslatedAiCharacterDetail? = null - if (!languageCode.isNullOrBlank() && languageCode != character.languageCode) { - val locale = languageCode.lowercase() - + if (langContext.lang.code != character.languageCode) { val existing = aiCharacterTranslationRepository - .findByCharacterIdAndLocale(character.id!!, locale) + .findByCharacterIdAndLocale(character.id!!, langContext.lang.code) if (existing != null) { val payload = existing.renderedPayload @@ -344,7 +233,7 @@ class ChatCharacterController( request = TranslateRequest( texts = texts, sourceLanguage = sourceLanguage, - targetLanguage = locale + targetLanguage = langContext.lang.code ) ) @@ -387,7 +276,7 @@ class ChatCharacterController( val entity = AiCharacterTranslation( characterId = character.id!!, - locale = locale, + locale = langContext.lang.code, renderedPayload = payload ) @@ -427,27 +316,22 @@ class ChatCharacterController( * * 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다 */ - val translatedOthers = if (!languageCode.isNullOrBlank()) { - val characterIds = others.map { it.characterId } + val characterIds = others.map { it.characterId } + val translatedOthers = if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .associateBy { it.characterId } - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } + others.map { other -> + val payload = translations[other.characterId]?.renderedPayload + val translatedName = payload?.name + val translatedTags = payload?.tags - others.map { other -> - val payload = translations[other.characterId]?.renderedPayload - val translatedName = payload?.name - val translatedTags = payload?.tags - - if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) { - other - } else { - other.copy(name = translatedName, tags = translatedTags) - } + if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) { + other + } else { + other.copy(name = translatedName, tags = translatedTags) } - } else { - others } } else { others @@ -488,7 +372,6 @@ class ChatCharacterController( */ @GetMapping("/recent") fun getRecentCharacters( - @RequestParam(required = false) languageCode: String? = null, @RequestParam("page", required = false) page: Int? ): ApiResponse = run { val characterPage = service.getRecentCharactersPage( @@ -496,41 +379,9 @@ class ChatCharacterController( size = 20 ) - /** - * 최근 등록된 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다 - */ - val translatedContent = if (!languageCode.isNullOrBlank()) { - val characterIds = characterPage.content.map { it.characterId } - - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } - - characterPage.content.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } - } - } else { - characterPage.content - } - } else { - characterPage.content - } - val translatedCharacterPage = RecentCharactersResponse( totalCount = characterPage.totalCount, - content = translatedContent + content = getTranslatedAiCharacterList(characterPage.content) ) ApiResponse.ok(translatedCharacterPage) @@ -543,7 +394,6 @@ class ChatCharacterController( */ @GetMapping("/recommend") fun getRecommendCharacters( - @RequestParam(required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { val recent = if (member == null || member.auth == null) { @@ -553,40 +403,48 @@ class ChatCharacterController( .listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려 .map { it.characterId } } - val characterList = service.getRecommendCharacters(recent, 20) - /** - * 추천 캐릭터 이름 번역 데이터 조회 - * - * languageCode != null - * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale - * - * 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다 - */ - val translatedCharacterList = if (!languageCode.isNullOrBlank()) { - val characterIds = characterList.map { it.characterId } + ApiResponse.ok( + getTranslatedAiCharacterList( + service.getRecommendCharacters( + recent, + 20 + ) + ) + ) + } - if (characterIds.isNotEmpty()) { - val translations = aiCharacterTranslationRepository - .findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode) - .associateBy { it.characterId } + /** + * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서 + * 번역 데이터를 한 번에 조회한다. + * - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만 + * 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다. + * + * @param aiCharacterList 번역 대상 캐릭터 목록 + * @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지 + */ + private fun getTranslatedAiCharacterList(aiCharacterList: List): List { + val characterIds = aiCharacterList.map { it.characterId } - characterList.map { character -> - val translatedName = translations[character.characterId]?.renderedPayload?.name - val translatedDesc = translations[character.characterId]?.renderedPayload?.description - if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { - character - } else { - character.copy(name = translatedName, description = translatedDesc) - } + return if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .associateBy { it.characterId } + + aiCharacterList.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + val translatedDesc = translations[character.characterId]?.renderedPayload?.description + if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName, description = translatedDesc) } - } else { - characterList } } else { - characterList + aiCharacterList } - - ApiResponse.ok(translatedCharacterList) } } -- 2.49.1 From 59949e5aeed31976c7f65c55c30400c554152b1d Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 19:40:21 +0900 Subject: [PATCH 39/90] =?UTF-8?q?AudioContent=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=EC=97=90=EC=84=9C=20api=20=EB=A7=88=EB=8B=A4=20languageCode?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=84=EB=8F=84=EB=A1=9C=20=EB=B0=9B=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20LangContext=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentController.kt | 10 +-- .../sodalive/content/AudioContentService.kt | 75 ++++++++----------- .../sodalive/explorer/ExplorerService.kt | 1 - 3 files changed, 33 insertions(+), 53 deletions(-) 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 9c1f24fb..5cddb914 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -108,7 +108,6 @@ class AudioContentController(private val service: AudioContentService) { @RequestParam("category-id", required = false) categoryId: Long? = 0, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, - @RequestParam("languageCode", required = false) languageCode: String? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { @@ -121,7 +120,6 @@ class AudioContentController(private val service: AudioContentService) { categoryId = categoryId ?: 0, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, - languageCode = languageCode, member = member, offset = pageable.offset, limit = pageable.pageSize.toLong() @@ -133,7 +131,6 @@ class AudioContentController(private val service: AudioContentService) { fun getDetail( @PathVariable id: Long, @RequestParam timezone: String, - @RequestParam(required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { @@ -144,8 +141,7 @@ class AudioContentController(private val service: AudioContentService) { id = id, member = member, isAdultContentVisible = isAdultContentVisible ?: true, - timezone = timezone, - languageCode = languageCode + timezone = timezone ) ) } @@ -243,7 +239,6 @@ class AudioContentController(private val service: AudioContentService) { @GetMapping("/all") fun getAllContents( - @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @RequestParam("isFree", required = false) isFree: Boolean? = null, @@ -264,8 +259,7 @@ class AudioContentController(private val service: AudioContentService) { sortType = sortType ?: SortType.NEWEST, isFree = isFree ?: false, isAdult = (isAdultContentVisible ?: true) && member.auth != null, - isPointAvailableOnly = isPointAvailableOnly ?: false, - languageCode = languageCode + isPointAvailableOnly = isPointAvailableOnly ?: false ) ) } 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 f7183192..bdc6122c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -29,6 +29,7 @@ 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.LangContext import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService @@ -72,6 +73,8 @@ class AudioContentService( private val audioContentCloudFront: AudioContentCloudFront, private val applicationEventPublisher: ApplicationEventPublisher, + private val langContext: LangContext, + @Value("\${cloud.aws.s3.content-bucket}") private val audioContentBucket: String, @@ -526,8 +529,7 @@ class AudioContentService( id: Long, member: Member, isAdultContentVisible: Boolean, - timezone: String, - languageCode: String? + timezone: String ): GetAudioContentDetailResponse { val isAdult = member.auth != null && isAdultContentVisible @@ -764,13 +766,10 @@ class AudioContentService( if ( audioContent.languageCode != null && audioContent.languageCode!!.isNotBlank() && - !languageCode.isNullOrBlank() && - audioContent.languageCode != languageCode + audioContent.languageCode != langContext.lang.code ) { - val locale = languageCode.lowercase() - val existing = contentTranslationRepository - .findByContentIdAndLocale(audioContent.id!!, locale) + .findByContentIdAndLocale(audioContent.id!!, langContext.lang.code) if (existing != null) { val payload = existing.renderedPayload @@ -791,7 +790,7 @@ class AudioContentService( request = TranslateRequest( texts = texts, sourceLanguage = sourceLanguage, - targetLanguage = locale + targetLanguage = langContext.lang.code ) ) @@ -812,7 +811,7 @@ class AudioContentService( contentTranslationRepository.save( ContentTranslation( contentId = audioContent.id!!, - locale = locale, + locale = langContext.lang.code, renderedPayload = payload ) ) @@ -928,7 +927,6 @@ class AudioContentService( categoryId: Long = 0, isAdultContentVisible: Boolean, contentType: ContentType, - languageCode: String?, offset: Long, limit: Long ): GetAudioContentListResponse { @@ -982,24 +980,19 @@ class AudioContentService( it } - val translatedContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = items.map { it.contentId } + val contentIds = items.map { it.contentId } + val translatedContentList = if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - items.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + items.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - items } } else { items @@ -1145,8 +1138,7 @@ class AudioContentService( isFree: Boolean = false, isAdult: Boolean = false, orderByRandom: Boolean = false, - isPointAvailableOnly: Boolean = false, - languageCode: String? = null + isPointAvailableOnly: Boolean = false ): List { val contentList = repository.getLatestContentByTheme( theme = theme, @@ -1160,24 +1152,19 @@ class AudioContentService( isPointAvailableOnly = isPointAvailableOnly ) - return if (!languageCode.isNullOrBlank()) { - val contentIds = contentList.map { it.contentId } + val contentIds = contentList.map { it.contentId } + return if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - contentList } } else { contentList diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 73dd9e38..083ab78d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -227,7 +227,6 @@ class ExplorerService( sortType = SortType.NEWEST, isAdultContentVisible = isAdultContentVisible, contentType = ContentType.ALL, - languageCode = null, member = member, offset = 0, limit = 3 -- 2.49.1 From de60a7073379802c17be523e9cc97ad870b8dc45 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 19:44:08 +0900 Subject: [PATCH 40/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EC=97=90=EC=84=9C=20languageCode=EB=A5=BC=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=20=EB=B0=9B=EB=8D=98=20=EA=B2=83=EC=9D=84=20?= =?UTF-8?q?LangContext=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/explorer/ExplorerController.kt | 2 -- .../sodalive/explorer/ExplorerService.kt | 31 +++++++++---------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index bf367df8..b47792a5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -52,7 +52,6 @@ class ExplorerController(private val service: ExplorerService) { fun getCreatorProfile( @PathVariable("id") creatorId: Long, @RequestParam timezone: String, - @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { @@ -61,7 +60,6 @@ class ExplorerController(private val service: ExplorerService) { service.getCreatorProfile( creatorId = creatorId, timezone = timezone, - languageCode = languageCode, isAdultContentVisible = isAdultContentVisible ?: true, member = member ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 083ab78d..c438b835 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -49,6 +50,8 @@ class ExplorerService( private val applicationEventPublisher: ApplicationEventPublisher, private val contentTranslationRepository: ContentTranslationRepository, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -172,7 +175,6 @@ class ExplorerService( fun getCreatorProfile( creatorId: Long, timezone: String, - languageCode: String?, isAdultContentVisible: Boolean, member: Member ): GetCreatorProfileResponse { @@ -235,24 +237,19 @@ class ExplorerService( listOf() } - val translatedContentList = if (!languageCode.isNullOrBlank() && contentList.isNotEmpty()) { - val contentIds = contentList.map { it.contentId } + val contentIds = contentList.map { it.contentId } + val translatedContentList = if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - contentList } } else { contentList -- 2.49.1 From 920a866ae0ff3740e5aa9f9196af677667a2d915 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Dec 2025 19:46:29 +0900 Subject: [PATCH 41/90] =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=A1=B0=ED=9A=8C=20API=EC=97=90=EC=84=9C=20langua?= =?UTF-8?q?geCode=EB=A5=BC=20=EB=B3=84=EB=8F=84=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=EC=9D=84=20LangContext=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/AudioContentMainController.kt | 2 -- .../content/main/AudioContentMainService.kt | 31 +++++++++---------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt index 892306cd..c534be5d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -100,7 +100,6 @@ class AudioContentMainController( @GetMapping("/new/all") fun getNewContentAllByTheme( @RequestParam("theme") theme: String, - @RequestParam("languageCode", required = false) languageCode: String? = null, @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @@ -113,7 +112,6 @@ class AudioContentMainController( theme = theme, isAdultContentVisible = isAdultContentVisible ?: true, contentType = contentType ?: ContentType.ALL, - languageCode = languageCode, member = member, pageable = pageable ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index addeb8e4..d6977a2d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationRes import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.event.EventItem +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import org.springframework.beans.factory.annotation.Value @@ -24,6 +25,8 @@ class AudioContentMainService( private val contentTranslationRepository: ContentTranslationRepository, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -60,7 +63,6 @@ class AudioContentMainService( theme: String, isAdultContentVisible: Boolean, contentType: ContentType, - languageCode: String?, member: Member, pageable: Pageable ): GetNewContentAllResponse { @@ -91,24 +93,19 @@ class AudioContentMainService( ) .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } - val translatedContentList = if (!languageCode.isNullOrBlank()) { - val contentIds = contentList.map { it.contentId } + val contentIds = contentList.map { it.contentId } + val translatedContentList = if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } - if (contentIds.isNotEmpty()) { - val translations = contentTranslationRepository - .findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode) - .associateBy { it.contentId } - - contentList.map { item -> - val translatedTitle = translations[item.contentId]?.renderedPayload?.title - if (translatedTitle.isNullOrBlank()) { - item - } else { - item.copy(title = translatedTitle) - } + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) } - } else { - contentList } } else { contentList -- 2.49.1 From 6f0619e482c7a353fecb8a2d576fad061a53b301 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Dec 2025 00:19:48 +0900 Subject: [PATCH 42/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EC=A0=80=EC=9E=A5=EC=8B=9C=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?API=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B8=B0=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 --- .../content/theme/AdminContentThemeService.kt | 14 +++++- .../content/theme/AudioContentTheme.kt | 2 +- .../translation/ContentThemeTranslation.kt | 11 +++++ .../ContentThemeTranslationRepository.kt | 7 +++ .../translation/LanguageTranslationEvent.kt | 48 ++++++++++++++++++- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt index 00e62373..896f636b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt @@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,6 +21,8 @@ class AdminContentThemeService( private val objectMapper: ObjectMapper, private val repository: AdminContentThemeRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String ) { @@ -37,7 +42,14 @@ class AdminContentThemeService( } fun createTheme(theme: String, imagePath: String) { - repository.save(AudioContentTheme(theme = theme, image = imagePath)) + val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath)) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = savedTheme.id!!, + targetType = LanguageTranslationTargetType.CONTENT_THEME + ) + ) } fun themeExistCheck(request: CreateContentThemeRequest) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt index 621b4444..6b6b7c07 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentTheme.kt @@ -7,7 +7,7 @@ import javax.persistence.Table @Entity @Table(name = "content_theme") -data class AudioContentTheme( +class AudioContentTheme( @Column(nullable = false) var theme: String, @Column(nullable = false) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslation.kt new file mode 100644 index 00000000..ec611030 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslation.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.content.theme.translation + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity + +@Entity +class ContentThemeTranslation( + val contentThemeId: Long, + val locale: String, + var theme: String +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt new file mode 100644 index 00000000..7bee3c0c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.theme.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface ContentThemeTranslationRepository : JpaRepository { + fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index f0ba1cf0..62db600f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -7,6 +7,9 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository @@ -21,7 +24,8 @@ import org.springframework.transaction.event.TransactionalEventListener enum class LanguageTranslationTargetType { CONTENT, - CHARACTER + CHARACTER, + CONTENT_THEME } class LanguageTranslationEvent( @@ -33,9 +37,11 @@ class LanguageTranslationEvent( class LanguageTranslationListener( private val audioContentRepository: AudioContentRepository, private val chatCharacterRepository: ChatCharacterRepository, + private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val translationService: PapagoTranslationService ) { @@ -46,6 +52,7 @@ class LanguageTranslationListener( when (event.targetType) { LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) + LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) } } @@ -202,4 +209,43 @@ class LanguageTranslationListener( } } } + + private fun handleContentThemeLanguageTranslation(event: LanguageTranslationEvent) { + val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(event.id) ?: return + + val sourceLanguage = "ko" + getTranslatableLanguageCodes(sourceLanguage).forEach { locale -> + val texts = mutableListOf() + texts.add(contentTheme.theme) + + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = sourceLanguage, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + val translatedTheme = translatedTexts[0] + + val existing = contentThemeTranslationRepository + .findByContentThemeIdAndLocale(contentTheme.id!!, locale) + + if (existing == null) { + contentThemeTranslationRepository.save( + ContentThemeTranslation( + contentThemeId = contentTheme.id!!, + locale = locale, + theme = translatedTheme + ) + ) + } else { + existing.theme = translatedTheme + contentThemeTranslationRepository.save(existing) + } + } + } + } } -- 2.49.1 From 13029ab8d2dd256ad2c3d60805a5ca3498e3bccb Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Dec 2025 00:51:07 +0900 Subject: [PATCH 43/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EB=B2=88=EC=97=AD=20N+1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온라인 경로에서 콘텐츠 테마 번역을 배치 조회/번역/저장으로 처리. - 기존 번역은 IN 조회, 미번역만 한 번의 번역 요청 후 저장. - 결과 순서 보전, 번역 누락/실패 시 원문으로 폴백. - 공개 API 변경 없음. --- .../theme/AudioContentThemeQueryRepository.kt | 67 ++++++++++++++++ .../content/theme/AudioContentThemeService.kt | 80 ++++++++++++++++++- .../ContentThemeTranslationRepository.kt | 2 + 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt index f4c2bbdb..fec94748 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt @@ -15,6 +15,10 @@ class AudioContentThemeQueryRepository( @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { + data class ThemeIdAndName( + val id: Long, + val theme: String + ) fun getActiveThemes(): List { return queryFactory .select( @@ -88,6 +92,69 @@ class AudioContentThemeQueryRepository( return query.fetch() } + fun getActiveThemeWithIdsOfContent( + isAdult: Boolean = false, + isFree: Boolean = false, + isPointAvailableOnly: Boolean = false, + contentType: ContentType + ): List { + var where = audioContent.isActive.isTrue + .and(audioContentTheme.isActive.isTrue) + + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } else { + if (contentType != ContentType.ALL) { + where = where.and( + audioContent.member.isNull.or( + audioContent.member.auth.gender.eq( + if (contentType == ContentType.MALE) { + 0 + } else { + 1 + } + ) + ) + ) + } + } + + if (isFree) { + where = where.and(audioContent.price.loe(0)) + } + + if (isPointAvailableOnly) { + where = where.and(audioContent.isPointAvailable.isTrue) + } + + val query = queryFactory + .select(audioContentTheme.id, audioContentTheme.theme) + .from(audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + .where(where) + .groupBy(audioContentTheme.id) + + if (isFree) { + query.orderBy( + CaseBuilder() + .`when`(audioContentTheme.theme.eq("자기소개")).then(0) + .otherwise(1) + .asc(), + audioContentTheme.orders.asc() + ) + } else { + query.orderBy(audioContentTheme.orders.asc()) + } + + return query.fetch().map { tuple -> + ThemeIdAndName( + id = tuple.get(audioContentTheme.id)!!, + theme = tuple.get(audioContentTheme.theme)!! + ) + } + } + fun findThemeByIdAndActive(id: Long): AudioContentTheme? { return queryFactory .selectFrom(audioContentTheme) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index 385b2706..2adff9d2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest import kr.co.vividnext.sodalive.member.Member import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,26 +18,94 @@ import org.springframework.transaction.annotation.Transactional @Service class AudioContentThemeService( private val queryRepository: AudioContentThemeQueryRepository, - private val contentRepository: AudioContentRepository + private val contentRepository: AudioContentRepository, + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + + private val papagoTranslationService: PapagoTranslationService, + private val langContext: LangContext ) { @Transactional(readOnly = true) fun getThemes(): List { return queryRepository.getActiveThemes() } - @Transactional(readOnly = true) + @Transactional fun getActiveThemeOfContent( isAdult: Boolean = false, isFree: Boolean = false, isPointAvailableOnly: Boolean = false, contentType: ContentType ): List { - return queryRepository.getActiveThemeOfContent( + val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent( isAdult = isAdult, isFree = isFree, isPointAvailableOnly = isPointAvailableOnly, contentType = contentType ) + + /** + * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 + * 번역이 없으면 번역 API 호출 후 저장하고 반환 + */ + val currentLang = langContext.lang + if (currentLang == Lang.EN || currentLang == Lang.JA) { + val targetLocale = currentLang.code + // 1) 기존 번역을 한 번에 조회 + val ids = themesWithIds.map { it.id } + val existingTranslations = if (ids.isNotEmpty()) { + contentThemeTranslationRepository.findByContentThemeIdInAndLocale(ids, targetLocale) + } else { + emptyList() + } + + val existingMap = existingTranslations.associateBy { it.contentThemeId } + + // 2) 미번역 항목만 수집하여 한 번에 번역 요청 + val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null } + + if (untranslatedPairs.isNotEmpty()) { + val texts = untranslatedPairs.map { it.theme } + + val response = papagoTranslationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = "ko", + targetLanguage = targetLocale + ) + ) + + val translatedTexts = response.translatedText + val entitiesToSave = mutableListOf() + + // translatedTexts 크기가 다르면 안전하게 원문으로 대체 + untranslatedPairs.forEachIndexed { index, pair -> + val translated = translatedTexts.getOrNull(index) ?: pair.theme + entitiesToSave.add( + ContentThemeTranslation( + contentThemeId = pair.id, + locale = targetLocale, + theme = translated + ) + ) + } + + if (entitiesToSave.isNotEmpty()) { + contentThemeTranslationRepository.saveAll(entitiesToSave) + } + + // 저장 후 맵을 갱신 + entitiesToSave.forEach { entity -> + (existingMap as MutableMap)[entity.contentThemeId] = entity + } + } + + // 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback) + return themesWithIds.map { pair -> + existingMap[pair.id]?.theme ?: pair.theme + } + } + + return themesWithIds.map { it.theme } } @Transactional(readOnly = true) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt index 7bee3c0c..546f0058 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface ContentThemeTranslationRepository : JpaRepository { fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation? + + fun findByContentThemeIdInAndLocale(contentThemeIds: Collection, locale: String): List } -- 2.49.1 From c0c61da44bafc71ea3a920619b8d711795aa2c0c Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Dec 2025 11:45:56 +0900 Subject: [PATCH 44/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/main/AudioContentMainService.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index d6977a2d..b226f1ea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.i18n.LangContext @@ -22,6 +23,7 @@ class AudioContentMainService( private val repository: AudioContentRepository, private val blockMemberRepository: BlockMemberRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository, + private val audioContentThemeService: AudioContentThemeService, private val contentTranslationRepository: ContentTranslationRepository, @@ -31,9 +33,21 @@ class AudioContentMainService( private val imageHost: String ) { @Transactional(readOnly = true) - @Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult") fun getThemeList(isAdult: Boolean, contentType: ContentType): List { - return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType) + /** + * 콘텐츠 테마 조회 + * + * - langContext에 따라 기본 한국어 데이터 혹은 번역된 콘텐츠 테마를 조회해야 함 + * + * - 번역된 테마 데이터가 없다면 번역하여 반환 + * - 번역된 데이터가 있다면 번역된 데이터를 조회하여 반환 + */ + // 표시용 테마 목록은 언어 컨텍스트에 따라 번역된 값을 반환해야 한다. + // AudioContentThemeService가 번역/저장을 처리하므로 이를 사용한다. + return audioContentThemeService.getActiveThemeOfContent( + isAdult = isAdult, + contentType = contentType + ) } @Transactional(readOnly = true) -- 2.49.1 From dc0df812320ce5440b83d8aad971fab7ded004fd Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Dec 2025 12:15:31 +0900 Subject: [PATCH 45/90] =?UTF-8?q?=EB=B2=88=EC=97=AD=EB=90=9C=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=EB=A1=9C=20=EC=BD=98=ED=85=90=EC=B8=A0=EB=A5=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=B4=EB=8F=84=20=ED=95=9C=EA=B8=80=20?= =?UTF-8?q?=ED=85=8C=EB=A7=88=EC=B2=98=EB=9F=BC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/AudioContentService.kt | 73 ++++++++++++++++++- .../content/main/AudioContentMainService.kt | 65 ++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) 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 bdc6122c..e443568a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -21,6 +21,7 @@ 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.theme.translation.ContentThemeTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository @@ -75,6 +76,8 @@ class AudioContentService( private val langContext: LangContext, + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + @Value("\${cloud.aws.s3.content-bucket}") private val audioContentBucket: String, @@ -1140,8 +1143,20 @@ class AudioContentService( orderByRandom: Boolean = false, isPointAvailableOnly: Boolean = false ): List { + /** + * - AS-IS theme은 한글만 처리하도록 되어 있음 + * - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리 + */ + val normalizedTheme = normalizeThemeForQuery( + themes = theme, + contentType = contentType, + isFree = isFree, + isAdult = isAdult, + isPointAvailableOnly = isPointAvailableOnly + ) + val contentList = repository.getLatestContentByTheme( - theme = theme, + theme = normalizedTheme, contentType = contentType, offset = offset, limit = limit, @@ -1170,4 +1185,60 @@ class AudioContentService( contentList } } + + /** + * theme 파라미터로 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다. + * - 현재 언어(locale)에 해당하는 테마 번역 목록을 활성 테마 집합과 매칭하여 역매핑한다. + * - 입력이 이미 한글인 경우 그대로 유지한다. + * - 매칭 실패 시 원본 값을 유지한다. + */ + private fun normalizeThemeForQuery( + themes: List, + contentType: ContentType, + isFree: Boolean, + isAdult: Boolean, + isPointAvailableOnly: Boolean + ): List { + if (themes.isEmpty()) return themes + + val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent( + isAdult = isAdult, + isFree = isFree, + isPointAvailableOnly = isPointAvailableOnly, + contentType = contentType + ) + + if (themesWithIds.isEmpty()) return themes + + val idByKorean = themesWithIds.associate { it.theme to it.id } + val koreanById = themesWithIds.associate { it.id to it.theme } + + val locale = langContext.lang.code + // 번역 테마를 역매핑하기 위해 현재 locale의 번역 목록을 조회 + val translatedByTextToId = run { + val ids = themesWithIds.map { it.id } + if (ids.isEmpty()) { + emptyMap() + } else { + contentThemeTranslationRepository + .findByContentThemeIdInAndLocale(ids, locale) + .associate { it.theme to it.contentThemeId } + } + } + + return themes.asSequence() + .map { input -> + when { + idByKorean.containsKey(input) -> input // 이미 한글 원문 + translatedByTextToId.containsKey(input) -> { + val id = translatedByTextToId[input]!! + koreanById[id] ?: input + } + + else -> input + } + } + .distinct() + .toList() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index b226f1ea..9317544b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerRespons import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.i18n.LangContext @@ -25,6 +26,8 @@ class AudioContentMainService( private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val audioContentThemeService: AudioContentThemeService, + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + private val contentTranslationRepository: ContentTranslationRepository, private val langContext: LangContext, @@ -80,8 +83,12 @@ class AudioContentMainService( member: Member, pageable: Pageable ): GetNewContentAllResponse { + /** + * - AS-IS theme은 한글만 처리하도록 되어 있음 + * - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리 + */ val isAdult = member.auth != null && isAdultContentVisible - val themeList = if (theme.isBlank()) { + val themeListRaw = if (theme.isBlank()) { audioContentThemeRepository.getActiveThemeOfContent( isAdult = isAdult, contentType = contentType @@ -90,6 +97,12 @@ class AudioContentMainService( listOf(theme) } + val themeList = normalizeThemeForQuery( + themes = themeListRaw, + contentType = contentType, + isAdult = isAdult + ) + val totalCount = repository.totalCountNewContentFor2Weeks( themeList, memberId = member.id!!, @@ -128,6 +141,56 @@ class AudioContentMainService( return GetNewContentAllResponse(totalCount, translatedContentList) } + /** + * 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다. + */ + private fun normalizeThemeForQuery( + themes: List, + contentType: ContentType, + isAdult: Boolean + ): List { + if (themes.isEmpty()) return themes + + val themesWithIds = audioContentThemeRepository.getActiveThemeWithIdsOfContent( + isAdult = isAdult, + isFree = false, + isPointAvailableOnly = false, + contentType = contentType + ) + + if (themesWithIds.isEmpty()) return themes + + val idByKorean = themesWithIds.associate { it.theme to it.id } + val koreanById = themesWithIds.associate { it.id to it.theme } + + val locale = langContext.lang.code + val translatedByTextToId = run { + val ids = themesWithIds.map { it.id } + if (ids.isEmpty()) { + emptyMap() + } else { + contentThemeTranslationRepository + .findByContentThemeIdInAndLocale(ids, locale) + .associate { it.theme to it.contentThemeId } + } + } + + return themes.asSequence() + .map { input -> + when { + idByKorean.containsKey(input) -> input + translatedByTextToId.containsKey(input) -> { + val id = translatedByTextToId[input]!! + koreanById[id] ?: input + } + + else -> input + } + } + .distinct() + .toList() + } + @Transactional(readOnly = true) @Cacheable(cacheNames = ["default"], key = "'newContentUploadCreatorList:' + #memberId + ':' + #isAdult") fun getNewContentUploadCreatorList(memberId: Long, isAdult: Boolean): List { -- 2.49.1 From 45ee55028f7a75b2755de4b573252f441e68e95c Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Dec 2025 12:25:10 +0900 Subject: [PATCH 46/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20-=20themeStr=20=EC=96=B8=EC=96=B4=EB=B3=84=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=A0=9C=EA=B3=B5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/AudioContentService.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) 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 e443568a..7bbacb66 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -828,6 +828,22 @@ class AudioContentService( } } + /** + * themeStr 번역 처리 + */ + val themeStrTranslated = run { + val theme = audioContent.theme + if (theme?.id != null) { + val locale = langContext.lang.code + val translated = contentThemeTranslationRepository + .findByContentThemeIdAndLocale(theme.id!!, locale) + val text = translated?.theme + if (!text.isNullOrBlank()) text else theme.theme + } else { + audioContent.theme!!.theme + } + } + return GetAudioContentDetailResponse( contentId = audioContent.id!!, title = audioContent.title, @@ -835,7 +851,7 @@ class AudioContentService( languageCode = audioContent.languageCode, coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", contentUrl = audioContentUrl, - themeStr = audioContent.theme!!.theme, + themeStr = themeStrTranslated, tag = tag, price = audioContent.price, duration = audioContent.duration ?: "", -- 2.49.1 From e00a9ccff5784876b35981c5ed49607cca2bda9f Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Dec 2025 16:27:29 +0900 Subject: [PATCH 47/90] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8,=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B2=88=EC=97=AD=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../translation/SeriesGenreTranslation.kt | 11 ++++ .../series/translation/SeriesTranslation.kt | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt new file mode 100644 index 00000000..9de7c1c7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.content.series.translation + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity + +@Entity +class SeriesGenreTranslation( + val seriesGenreId: Long, + val locale: String, + val genre: String +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt new file mode 100644 index 00000000..75dbbdd2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.content.series.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 + +@Entity +class SeriesTranslation( + val seriesId: Long, + val locale: String, + + @Column(columnDefinition = "json") + @Convert(converter = SeriesTranslationPayloadConverter::class) + var renderedPayload: SeriesTranslationPayload +) : BaseEntity() + +data class SeriesTranslationPayload( + val title: String, + val introduction: String, + val keywords: String +) + +@Converter(autoApply = false) +class SeriesTranslationPayloadConverter : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: SeriesTranslationPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): SeriesTranslationPayload { + if (dbData.isNullOrBlank()) { + return SeriesTranslationPayload( + title = "", + introduction = "", + keywords = "" + ) + } + return objectMapper.readValue(dbData) + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} -- 2.49.1 From 9b2b156d406b3ad840183f4b087de1fe6cf65b1b Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Dec 2025 23:55:50 +0900 Subject: [PATCH 48/90] =?UTF-8?q?SeriesTranslationPayload=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `SeriesTranslationPayload.keywords` 타입을 `String`에서 `List`으로 변경했습니다. - `SeriesTranslationPayloadConverter`의 `convertToEntityAttribute`를 하위 호환 가능하도록 수정했습니다. - DB에 저장된 JSON에서 `keywords`가 과거 스키마(String)인 경우와 신규 스키마(List)를 모두 안전하게 파싱합니다. - 파싱 실패 또는 공백 입력 시 기본값을 사용합니다(`keywords = []`). - `convertToDatabaseColumn`은 변경 없이 `ObjectMapper`로 직렬화하여 `keywords`가 배열로 저장됩니다. --- .../series/translation/SeriesTranslation.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt index 75dbbdd2..c661d48e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.content.series.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 @@ -22,7 +21,7 @@ class SeriesTranslation( data class SeriesTranslationPayload( val title: String, val introduction: String, - val keywords: String + val keywords: List ) @Converter(autoApply = false) @@ -38,10 +37,34 @@ class SeriesTranslationPayloadConverter : AttributeConverter = when { + keywordsNode == null || keywordsNode.isNull -> emptyList() + keywordsNode.isArray -> keywordsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() } + keywordsNode.isTextual -> listOfNotNull(keywordsNode.asText()).filter { it.isNotBlank() } + else -> emptyList() + } + SeriesTranslationPayload( + title = title, + introduction = introduction, + keywords = keywords + ) + } catch (_: Exception) { + // 파싱 실패 시 안전한 기본값 반환 + SeriesTranslationPayload( + title = "", + introduction = "", + keywords = emptyList() ) } - return objectMapper.readValue(dbData) } companion object { -- 2.49.1 From f58687ef3afc8c5e72b002dc86699a19a4d889f5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 00:25:24 +0900 Subject: [PATCH 49/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=8B=9C=20=EB=B2=88=EC=97=AD=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/AudioContentService.kt | 14 +-- .../sodalive/content/LanguageDetectEvent.kt | 45 ++++++- .../translation/SeriesGenreTranslation.kt | 12 +- .../SeriesGenreTranslationRepository.kt | 7 ++ .../SeriesTranslationRepository.kt | 7 ++ .../CreatorAdminContentSeriesService.kt | 41 ++++++ .../creator/admin/content/series/Series.kt | 1 + .../translation/LanguageTranslationEvent.kt | 118 +++++++++++++++++- 8 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt 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 7bbacb66..d3116a3c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -372,14 +372,14 @@ class AudioContentService( query = papagoQuery ) ) - } - - applicationEventPublisher.publishEvent( - LanguageTranslationEvent( - id = audioContent.id!!, - targetType = LanguageTranslationTargetType.CONTENT + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = audioContent.id!!, + targetType = LanguageTranslationTargetType.CONTENT + ) ) - ) + } return CreateAudioContentResponse(contentId = audioContent.id!!) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index fc552f61..b111de7c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -3,12 +3,14 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -29,7 +31,8 @@ enum class LanguageDetectTargetType { COMMENT, CHARACTER, CHARACTER_COMMENT, - CREATOR_CHEERS + CREATOR_CHEERS, + SERIES } class LanguageDetectEvent( @@ -49,6 +52,7 @@ class LanguageDetectListener( private val chatCharacterRepository: ChatCharacterRepository, private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, + private val seriesRepository: ContentSeriesRepository, private val applicationEventPublisher: ApplicationEventPublisher, @@ -80,6 +84,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) + LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) } } @@ -255,6 +260,44 @@ class LanguageDetectListener( ) } + private fun handleSeriesLanguageDetect(event: LanguageDetectEvent) { + val seriesId = event.id + + val series = seriesRepository.findByIdOrNull(seriesId) + if (series == null) { + log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!series.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}", + seriesId, + series.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return + + series.languageCode = langCode + seriesRepository.save(series) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = seriesId, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}", + seriesId, + langCode + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt index 9de7c1c7..a7f91a07 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt @@ -1,11 +1,21 @@ package kr.co.vividnext.sodalive.content.series.translation import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint @Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["series_genre_id", "locale"]) + ] +) class SeriesGenreTranslation( + @Column(name = "series_genre_id") val seriesGenreId: Long, + @Column(name = "locale") val locale: String, - val genre: String + var genre: String ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt new file mode 100644 index 00000000..e88589b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.series.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface SeriesGenreTranslationRepository : JpaRepository { + fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt new file mode 100644 index 00000000..bfb3b933 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.series.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface SeriesTranslationRepository : JpaRepository { + fun findBySeriesIdAndLocale(seriesId: Long, locale: String): SeriesTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt index eba94690..739aed22 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.hashtag.HashTag import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest @@ -12,9 +14,12 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveConte import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -30,6 +35,8 @@ class CreatorAdminContentSeriesService( private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.s3.bucket}") private val coverImageBucket: String, @@ -89,6 +96,31 @@ class CreatorAdminContentSeriesService( ) series.coverImage = coverImagePath + + if (series.languageCode.isNullOrBlank()) { + val papagoQuery = listOf( + request.title.trim(), + request.introduction.trim(), + request.keyword.trim() + ) + .filter { it.isNotBlank() } + .joinToString(" ") + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = series.id!!, + query = papagoQuery, + targetType = LanguageDetectTargetType.SERIES + ) + ) + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = series.id!!, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + } } @Transactional @@ -174,6 +206,15 @@ class CreatorAdminContentSeriesService( if (request.studio != null) { series.studio = request.studio } + + if (request.title != null || request.introduction != null) { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = series.id!!, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + } } fun getSeriesList(offset: Long, limit: Long, creatorId: Long): GetCreatorAdminContentSeriesListResponse { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt index 1bd336df..49b0744d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt @@ -34,6 +34,7 @@ data class Series( var title: String, @Column(columnDefinition = "TEXT", nullable = false) var introduction: String, + var languageCode: String? = null, @Enumerated(value = EnumType.STRING) var state: SeriesState = SeriesState.PROCEEDING, var writer: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index 62db600f..67cdb64c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.i18n.translation +import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository +import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload @@ -7,6 +9,11 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation +import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository @@ -25,7 +32,10 @@ import org.springframework.transaction.event.TransactionalEventListener enum class LanguageTranslationTargetType { CONTENT, CHARACTER, - CONTENT_THEME + CONTENT_THEME, + + SERIES, + SERIES_GENRE } class LanguageTranslationEvent( @@ -38,10 +48,14 @@ class LanguageTranslationListener( private val audioContentRepository: AudioContentRepository, private val chatCharacterRepository: ChatCharacterRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository, + private val seriesRepository: AdminContentSeriesRepository, + private val seriesGenreRepository: AdminContentSeriesGenreRepository, private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + private val seriesTranslationRepository: SeriesTranslationRepository, + private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val translationService: PapagoTranslationService ) { @@ -53,6 +67,8 @@ class LanguageTranslationListener( LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) + LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) + LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) } } @@ -248,4 +264,104 @@ class LanguageTranslationListener( } } } + + private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) { + val series = seriesRepository.findByIdOrNull(event.id) ?: return + val languageCode = series.languageCode + if (languageCode != null) return + + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val keywords = series.keywordList + .mapNotNull { it.keyword?.tag } + .joinToString(", ") + val texts = mutableListOf() + texts.add(series.title) + texts.add(series.introduction) + texts.add(keywords) + + val sourceLanguage = series.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 translatedIntroduction = translatedTexts[index++] + val translatedKeywordsJoined = translatedTexts[index] + + val translatedKeywords = translatedKeywordsJoined + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + + val payload = SeriesTranslationPayload( + title = translatedTitle, + introduction = translatedIntroduction, + keywords = translatedKeywords + ) + + val existing = seriesTranslationRepository + .findBySeriesIdAndLocale(series.id!!, locale) + + if (existing == null) { + seriesTranslationRepository.save( + SeriesTranslation( + seriesId = series.id!!, + locale = locale, + renderedPayload = payload + ) + ) + } else { + existing.renderedPayload = payload + seriesTranslationRepository.save(existing) + } + } + } + } + + private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) { + val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return + + val sourceLanguage = "ko" + getTranslatableLanguageCodes(sourceLanguage).forEach { locale -> + val texts = mutableListOf() + texts.add(seriesGenre.genre) + + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = sourceLanguage, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + val translatedGenre = translatedTexts[0] + + val existing = seriesGenreTranslationRepository + .findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale) + + if (existing == null) { + seriesGenreTranslationRepository.save( + SeriesGenreTranslation( + seriesGenreId = seriesGenre.id!!, + locale = locale, + genre = translatedGenre + ) + ) + } else { + existing.genre = translatedGenre + seriesGenreTranslationRepository.save(existing) + } + } + } + } } -- 2.49.1 From db18d5c8b5003f311b071a175e09d06e3a620417 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 00:43:36 +0900 Subject: [PATCH 50/90] =?UTF-8?q?=ED=99=88=20-=20=EC=98=A4=EC=A7=81=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=8A=A4=EC=98=A8=EC=97=90=EC=84=9C=EB=A7=8C?= =?UTF-8?q?,=20=EC=9A=94=EC=9D=BC=EB=B3=84=20=EC=8B=9C=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=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 --- .../sodalive/api/home/HomeService.kt | 49 ++++++++++++++++++- .../SeriesTranslationRepository.kt | 1 + 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 7e2581ba..e8ef45bf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek @@ -53,6 +54,7 @@ class HomeService( private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + private val seriesTranslationRepository: SeriesTranslationRepository, private val langContext: LangContext, @@ -133,20 +135,25 @@ class HomeService( isAdult = isAdult ) + // 오직 보이스온에서만 val originalAudioDramaList = seriesService.getOriginalAudioDramaList( isAdult = isAdult, contentType = contentType, orderByRandom = true ) + val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList) + val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) + // 요일별 시리즈 val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, contentType = contentType, dayOfWeek = getDayOfWeekByTimezone(timezone) ) + val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList) // 인기 캐릭터 조회 val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) @@ -280,9 +287,9 @@ class HomeService( latestContentList = translatedLatestContentList, bannerList = bannerList, eventBannerList = eventBannerList, - originalAudioDramaList = originalAudioDramaList, + originalAudioDramaList = translatedOriginalAudioDramaList, auditionList = auditionList, - dayOfWeekSeriesList = dayOfWeekSeriesList, + dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, contentRanking = translatedContentRanking, recommendChannelList = translatedRecommendChannelList, @@ -479,6 +486,44 @@ class HomeService( } } + /** + * 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + * + * @param seriesList 번역 대상 SeriesListItem 목록 + * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 + */ + private fun getTranslatedSeriesList( + seriesList: List + ): List { + val seriesIds = seriesList.map { it.seriesId } + + return if (seriesIds.isNotEmpty()) { + val translations = seriesTranslationRepository + .findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code) + .associateBy { it.seriesId } + + seriesList.map { item -> + val translatedTitle = translations[item.seriesId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + seriesList + } + } + /** * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. * diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt index bfb3b933..266bcbc7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt @@ -4,4 +4,5 @@ import org.springframework.data.jpa.repository.JpaRepository interface SeriesTranslationRepository : JpaRepository { fun findBySeriesIdAndLocale(seriesId: Long, locale: String): SeriesTranslation? + fun findBySeriesIdInAndLocale(seriesIds: List, locale: String): List } -- 2.49.1 From 0eed29eadc48dfaa6a35471170057d14020e3337 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 01:07:20 +0900 Subject: [PATCH 51/90] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20-=20=EB=B2=88=EC=97=AD=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesService.kt | 115 +++++++++++++++--- 1 file changed, 99 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 3a2cc9bd..f6c8c268 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -6,11 +6,14 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import org.springframework.beans.factory.annotation.Value @@ -26,6 +29,8 @@ class ContentSeriesService( private val blockMemberRepository: BlockMemberRepository, private val explorerQueryRepository: ExplorerQueryRepository, private val seriesContentRepository: ContentSeriesContentRepository, + private val langContext: LangContext, + private val seriesTranslationRepository: SeriesTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String @@ -83,7 +88,7 @@ class ContentSeriesService( ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) - return GetSeriesListResponse(totalCount, items) + return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items)) } fun getSeriesListByGenre( @@ -338,27 +343,105 @@ class ContentSeriesService( } private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set): String { + /** + * i18n을 적용하여 언어별로 요일 표시를 변경한다. + */ + val lang = langContext.lang + + val labelRandom = when (lang) { + Lang.EN -> "Random" + Lang.JA -> "ランダム" + else -> "랜덤" + } + val labels = when (lang) { + Lang.EN -> mapOf( + SeriesPublishedDaysOfWeek.SUN to "Sun", + SeriesPublishedDaysOfWeek.MON to "Mon", + SeriesPublishedDaysOfWeek.TUE to "Tue", + SeriesPublishedDaysOfWeek.WED to "Wed", + SeriesPublishedDaysOfWeek.THU to "Thu", + SeriesPublishedDaysOfWeek.FRI to "Fri", + SeriesPublishedDaysOfWeek.SAT to "Sat", + SeriesPublishedDaysOfWeek.RANDOM to labelRandom + ) + + Lang.JA -> mapOf( + SeriesPublishedDaysOfWeek.SUN to "日", + SeriesPublishedDaysOfWeek.MON to "月", + SeriesPublishedDaysOfWeek.TUE to "火", + SeriesPublishedDaysOfWeek.WED to "水", + SeriesPublishedDaysOfWeek.THU to "木", + SeriesPublishedDaysOfWeek.FRI to "金", + SeriesPublishedDaysOfWeek.SAT to "土", + SeriesPublishedDaysOfWeek.RANDOM to labelRandom + ) + + else -> mapOf( + SeriesPublishedDaysOfWeek.SUN to "일", + SeriesPublishedDaysOfWeek.MON to "월", + SeriesPublishedDaysOfWeek.TUE to "화", + SeriesPublishedDaysOfWeek.WED to "수", + SeriesPublishedDaysOfWeek.THU to "목", + SeriesPublishedDaysOfWeek.FRI to "금", + SeriesPublishedDaysOfWeek.SAT to "토", + SeriesPublishedDaysOfWeek.RANDOM to labelRandom + ) + } + val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal } - .map { - when (it) { - SeriesPublishedDaysOfWeek.SUN -> "일" - SeriesPublishedDaysOfWeek.MON -> "월" - SeriesPublishedDaysOfWeek.TUE -> "화" - SeriesPublishedDaysOfWeek.WED -> "수" - SeriesPublishedDaysOfWeek.THU -> "목" - SeriesPublishedDaysOfWeek.FRI -> "금" - SeriesPublishedDaysOfWeek.SAT -> "토" - SeriesPublishedDaysOfWeek.RANDOM -> "랜덤" - } - } + .map { labels[it] ?: it.name } .joinToString(", ") { it } - return if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)) { + val containsRandom = publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM) + return if (containsRandom) { dayOfWeekText } else if (publishedDaysOfWeek.size < 7) { - "매주 $dayOfWeekText" + when (lang) { + Lang.EN -> "Every $dayOfWeekText" + Lang.JA -> "毎週 $dayOfWeekText" + else -> "매주 $dayOfWeekText" + } } else { - "매일" + when (lang) { + Lang.EN -> "Daily" + Lang.JA -> "毎日" + else -> "매일" + } + } + } + + /** + * 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + * + * @param seriesList 번역 대상 SeriesListItem 목록 + * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 + */ + private fun getTranslatedSeriesList( + seriesList: List + ): List { + val seriesIds = seriesList.map { it.seriesId } + if (seriesIds.isEmpty()) return seriesList + + val translations = seriesTranslationRepository + .findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code) + .associateBy { it.seriesId } + + return seriesList.map { item -> + val translatedTitle = translations[item.seriesId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } } } } -- 2.49.1 From 4c0be733d01e5568d5bf6dfc4781dbf707a5f0cd Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 02:51:36 +0900 Subject: [PATCH 52/90] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20-=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesService.kt | 123 +++++++++++++++++- .../content/series/GetSeriesDetailResponse.kt | 6 +- .../series/translation/SeriesTranslation.kt | 6 + 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index f6c8c268..83e9a0b1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -6,7 +6,9 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse +import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository +import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType @@ -14,10 +16,13 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +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 org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -29,8 +34,12 @@ class ContentSeriesService( private val blockMemberRepository: BlockMemberRepository, private val explorerQueryRepository: ExplorerQueryRepository, private val seriesContentRepository: ContentSeriesContentRepository, + private val langContext: LangContext, + private val seriesTranslationRepository: SeriesTranslationRepository, + private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, + private val translationService: PapagoTranslationService, @Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String @@ -120,6 +129,7 @@ class ContentSeriesService( return GetSeriesListResponse(totalCount, items) } + @Transactional fun getSeriesDetail( seriesId: Long, isAdultContentVisible: Boolean, @@ -161,7 +171,115 @@ class ContentSeriesService( limit = 5 ) + /** + * series.languageCode != null && series.languageCode != languageCode + * + * 번역 시리즈를 조회한다. - series, locale + * 번역 콘텐츠가 있으면 + * TranslatedSeries로 가공한다 + * + * 번역 콘텐츠가 없으면 + * 파파고 API를 통해 번역한 후 저장한다. + * + * 번역 대상: title, introduction, keywordList + * + * 파파고로 번역한 데이터를 TranslatedSeries 가공한다 + */ + val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") + + // 요청된 언어(locale)에 대한 시리즈 번역을 조회하거나, 없으면 동기 번역 후 저장한다. + var translated: TranslatedSeries? = null + run { + val locale = langContext.lang.code + val languageCode = series.languageCode + // 원본 언어가 존재하고, 요청 언어와 다를 때만 번역 처리 + if (!languageCode.isNullOrBlank() && languageCode != locale) { + val existing = seriesTranslationRepository.findBySeriesIdAndLocale(seriesId = seriesId, locale = locale) + if (existing != null) { + val payload = existing.renderedPayload + val kws = payload.keywords.ifEmpty { keywordList } + translated = TranslatedSeries( + title = payload.title, + introduction = payload.introduction, + keywords = kws + ) + } else { + val texts = mutableListOf() + texts.add(series.title) + texts.add(series.introduction) + // 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다. + val keywordListForTranslate = keywordList + texts.addAll(keywordListForTranslate) + + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = languageCode, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + var index = 0 + val translatedTitle = translatedTexts[index++] + val translatedIntroduction = translatedTexts[index++] + val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) { + translatedTexts.subList(index, translatedTexts.size) + } else { + // 번역할 키워드가 없으면 원본 키워드 반환 정책 적용 + keywordList + } + + val payload = kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload( + title = translatedTitle, + introduction = translatedIntroduction, + keywords = translatedKeywords + ) + + seriesTranslationRepository.save( + kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation( + seriesId = seriesId, + locale = locale, + renderedPayload = payload + ) + ) + + val kws = translatedKeywords.ifEmpty { keywordList } + translated = TranslatedSeries( + title = translatedTitle, + introduction = translatedIntroduction, + keywords = kws + ) + } + } + } + } + + // 장르 번역 조회 (있으면 반환) + val translatedGenre: String? = run { + val genreId = series.genre?.id + if (genreId != null) { + val locale = langContext.lang.code + val found = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(genreId, locale) + val text = found?.genre + if (!text.isNullOrBlank()) { + text + } else { + null + } + } else { + null + } + } + + // publishedDateUtc는 ISO8601(Z 포함)로 반환 + val publishedDateUtc = series.createdAt!! + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString() + return GetSeriesDetailResponse( seriesId = seriesId, title = series.title, @@ -176,6 +294,7 @@ class ContentSeriesService( .withZoneSameInstant(ZoneId.of("Asia/Seoul")) .toLocalDateTime() .format(dateTimeFormatter), + publishedDateUtc = publishedDateUtc, creator = GetSeriesDetailResponse.GetSeriesDetailCreator( creatorId = series.member!!.id!!, nickname = series.member!!.nickname, @@ -191,7 +310,9 @@ class ContentSeriesService( keywordList = keywordList, publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek), contentList = seriesContentList.items, - contentCount = seriesContentList.totalCount + contentCount = seriesContentList.totalCount, + translated = translated, + translatedGenre = translatedGenre ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesDetailResponse.kt index 7b9daac2..30536ad5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesDetailResponse.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.content.series import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem +import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries data class GetSeriesDetailResponse( val seriesId: Long, @@ -12,6 +13,7 @@ data class GetSeriesDetailResponse( val writer: String?, val studio: String?, val publishedDate: String, + val publishedDateUtc: String, val creator: GetSeriesDetailCreator, var rentalMinPrice: Int, var rentalMaxPrice: Int, @@ -21,7 +23,9 @@ data class GetSeriesDetailResponse( val keywordList: List, val publishedDaysOfWeek: String, val contentList: List, - val contentCount: Int + val contentCount: Int, + val translated: TranslatedSeries?, + val translatedGenre: String? ) { data class GetSeriesDetailCreator( val creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt index c661d48e..cc346b25 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt @@ -71,3 +71,9 @@ class SeriesTranslationPayloadConverter : AttributeConverter +) -- 2.49.1 From 30a104981c0b5bfd1acefc7991f0c75ae57574dc Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 03:29:02 +0900 Subject: [PATCH 53/90] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20-=20=EB=B2=88=EC=97=AD=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/series/ContentSeriesService.kt | 6 ++++-- .../sodalive/i18n/translation/PapagoTranslationService.kt | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 83e9a0b1..8794eba4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation +import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries import kr.co.vividnext.sodalive.creator.admin.content.series.Series @@ -232,14 +234,14 @@ class ContentSeriesService( keywordList } - val payload = kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload( + val payload = SeriesTranslationPayload( title = translatedTitle, introduction = translatedIntroduction, keywords = translatedKeywords ) seriesTranslationRepository.save( - kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation( + SeriesTranslation( seriesId = seriesId, locale = locale, renderedPayload = payload 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 index 29a76b5d..1b01dffa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -6,6 +6,7 @@ import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.stereotype.Service import org.springframework.web.client.RestTemplate +import org.springframework.web.client.postForEntity @Service class PapagoTranslationService( @@ -46,10 +47,9 @@ class PapagoTranslationService( val requestEntity = HttpEntity(body, headers) - val response = restTemplate.postForEntity( + val response = restTemplate.postForEntity( papagoTranslateUrl, - requestEntity, - PapagoTranslationResponse::class.java + requestEntity ) if (!response.statusCode.is2xxSuccessful) { -- 2.49.1 From 4e4235369c256e3876022bd79be8492a50a340b4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 03:40:28 +0900 Subject: [PATCH 54/90] =?UTF-8?q?=ED=99=88=20=EC=9A=94=EC=9D=BC=EB=B3=84?= =?UTF-8?q?=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20-=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index e8ef45bf..11a3fe78 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -348,12 +348,14 @@ class HomeService( val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible - return seriesService.getDayOfWeekSeriesList( + val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, contentType = contentType, dayOfWeek = dayOfWeek ) + + return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList) } fun getContentRankingBySort( -- 2.49.1 From 82f53ed8ab79a1082abc5c4857d7c263317c7f9f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 04:09:25 +0900 Subject: [PATCH 55/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9,=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EC=9E=A5=EB=A5=B4?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD=20=EB=B0=98=ED=99=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesService.kt | 114 ++++++++++++++++-- .../SeriesGenreTranslationRepository.kt | 2 + 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 8794eba4..1f8dba56 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType @@ -41,6 +42,7 @@ class ContentSeriesService( private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, + private val contentTranslationRepository: ContentTranslationRepository, private val translationService: PapagoTranslationService, @Value("\${cloud.aws.cloud-front.host}") @@ -58,11 +60,77 @@ class ContentSeriesService( limit: Long = 20 ): List { val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit) - return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType) + return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)) } fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List { - return repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType) + /** + * langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환 + * 번역이 없으면 번역 API 호출 후 저장하고 반환 + */ + val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType) + + val currentLang = langContext.lang + if (currentLang == Lang.EN || currentLang == Lang.JA) { + val targetLocale = currentLang.code + val ids = genres.map { it.id } + + // 기존 번역 일괄 조회 + val existing = if (ids.isNotEmpty()) { + // 메서드가 없을 경우 개별 조회로 대체되지만, 성능을 위해 우선 시도 + try { + seriesGenreTranslationRepository + .findBySeriesGenreIdInAndLocale(ids, targetLocale) + } catch (_: Exception) { + // Spring Data 메서드 미존재 시 안전하게 개별 조회로 폴백 + ids.mapNotNull { id -> + seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(id, targetLocale) + } + } + } else { + emptyList() + } + + val existingMap = existing.associateBy { it.seriesGenreId }.toMutableMap() + + // 미번역 항목 수집 + val untranslated = genres.filter { existingMap[it.id] == null } + if (untranslated.isNotEmpty()) { + val texts = untranslated.map { it.genre } + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = "ko", + targetLanguage = targetLocale + ) + ) + + val translatedTexts = response.translatedText + val toSave = mutableListOf() + untranslated.forEachIndexed { index, item -> + val translated = translatedTexts.getOrNull(index) ?: item.genre + toSave.add( + kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation( + seriesGenreId = item.id, + locale = targetLocale, + genre = translated + ) + ) + } + if (toSave.isNotEmpty()) { + seriesGenreTranslationRepository.saveAll(toSave) + toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved } + } + } + + // 원래 순서 보존하여 결과 조립 + return genres.map { g -> + val translated = existingMap[g.id]?.genre ?: g.genre + GetSeriesGenreListResponse(id = g.id, genre = translated) + } + } + + return genres } fun getSeriesList( @@ -128,7 +196,7 @@ class ContentSeriesService( ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) - return GetSeriesListResponse(totalCount, items) + return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items)) } @Transactional @@ -356,7 +424,33 @@ class ContentSeriesService( it } - return GetSeriesContentListResponse(totalCount, contentList) + /** + * 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * contentTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + */ + val contentIds = contentList.map { it.contentId } + val translatedItems = if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } + + contentList.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) item else item.copy(title = translatedTitle) + } + } else { + contentList + } + + return GetSeriesContentListResponse(totalCount, translatedItems) } fun getRecommendSeriesList( @@ -371,7 +465,13 @@ class ContentSeriesService( limit = 20 ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } - return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType) + return getTranslatedSeriesList( + seriesToSeriesListItem( + seriesList = seriesList, + isAdult = isAuth, + contentType = contentType + ) + ) } fun fetchSeriesByCurationId( @@ -386,7 +486,7 @@ class ContentSeriesService( isAdult = isAdult, contentType = contentType ) - return seriesToSeriesListItem(seriesList, isAdult, contentType) + return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) } fun getDayOfWeekSeriesList( @@ -416,7 +516,7 @@ class ContentSeriesService( seriesList } - return seriesToSeriesListItem(seriesList, isAdult, contentType) + return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) } private fun seriesToSeriesListItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt index e88589b4..3e487365 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface SeriesGenreTranslationRepository : JpaRepository { fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation? + + fun findBySeriesGenreIdInAndLocale(seriesGenreIds: List, locale: String): List } -- 2.49.1 From 8ae6943c2a46f3b61206d234507f11e357d5c796 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 04:14:13 +0900 Subject: [PATCH 56/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=88=98=EC=A0=95=EC=8B=9C=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/content/CreatorAdminContentService.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt index 1c97e30b..aeaddddd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt @@ -10,9 +10,12 @@ import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag import kr.co.vividnext.sodalive.content.hashtag.HashTag import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -27,6 +30,8 @@ class CreatorAdminContentService( private val objectMapper: ObjectMapper, private val s3Uploader: S3Uploader, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, @@ -194,6 +199,13 @@ class CreatorAdminContentService( } audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.id, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) } } } -- 2.49.1 From 7955be45dad9a9b94a944ffd01aa907020c1bdf8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 06:10:18 +0900 Subject: [PATCH 57/90] =?UTF-8?q?=EC=9B=90=EC=9E=91=20=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=8B=9C=20=EB=B2=88=EC=97=AD=20API=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminOriginalWorkService.kt | 67 ++++++++++++- .../sodalive/chat/original/OriginalWork.kt | 4 + .../translation/OriginalWorkTranslation.kt | 94 +++++++++++++++++++ .../OriginalWorkTranslationRepository.kt | 7 ++ .../sodalive/content/LanguageDetectEvent.kt | 45 ++++++++- .../translation/LanguageTranslationEvent.kt | 91 +++++++++++++++++- 6 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index e02e6320..22b67b6b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -10,6 +10,11 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort @@ -24,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional class AdminOriginalWorkService( private val originalWorkRepository: OriginalWorkRepository, private val chatCharacterRepository: ChatCharacterRepository, - private val originalWorkTagRepository: OriginalWorkTagRepository + private val originalWorkTagRepository: OriginalWorkTagRepository, + + private val applicationEventPublisher: ApplicationEventPublisher ) { /** 원작 등록 (중복 제목 방지 포함) */ @@ -56,7 +63,44 @@ class AdminOriginalWorkService( entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity)) } } - return originalWorkRepository.save(entity) + + val originalWork = originalWorkRepository.save(entity) + + /** + * 저장이 완료된 후 + * originalWork의 + * + * languageCode == null이면 언어 감지 이벤트 호출 + * languageCode != null이면 번역 이벤트 호출 + * + */ + if (originalWork.languageCode == null) { + val papagoQuery = listOf( + originalWork.title, + originalWork.contentType, + originalWork.category, + originalWork.description + ) + .filter { it.isNotBlank() } + .joinToString(" ") + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = originalWork.id!!, + query = papagoQuery, + targetType = LanguageDetectTargetType.ORIGINAL_WORK + ) + ) + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = originalWork.id!!, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + } + + return originalWork } /** 원작 수정 (이미지 경로 포함 선택적 변경) */ @@ -107,6 +151,25 @@ class AdminOriginalWorkService( if (imagePath != null) { ow.imagePath = imagePath } + + /** + * 번역 이벤트 호출 + */ + if ( + request.title != null || + request.contentType != null || + request.category != null || + request.description != null || + request.tags != null + ) { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = ow.id!!, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + } + return originalWorkRepository.save(ow) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt index 543c635d..ceb19d21 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt @@ -33,6 +33,10 @@ class OriginalWork( @Column(columnDefinition = "TEXT") var description: String = "", + /** 언어 코드 */ + @Column(nullable = true) + var languageCode: String? = null, + /** 원천 원작 */ @Column(nullable = true) var originalWork: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt new file mode 100644 index 00000000..bdc85332 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt @@ -0,0 +1,94 @@ +package kr.co.vividnext.sodalive.chat.original.translation + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +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 = ["original_work_id", "locale"]) + ] +) +class OriginalWorkTranslation( + @Column(name = "original_work_id") + val originalWorkId: Long, + @Column(name = "locale") + val locale: String, + + @Column(columnDefinition = "json") + @Convert(converter = OriginalWorkTranslationPayloadConverter::class) + var renderedPayload: OriginalWorkTranslationPayload +) : BaseEntity() + +data class OriginalWorkTranslationPayload( + val title: String, + val contentType: String, + val category: String, + val description: String, + val tags: List +) + +@Converter(autoApply = false) +class OriginalWorkTranslationPayloadConverter : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload { + if (dbData.isNullOrBlank()) { + return OriginalWorkTranslationPayload( + title = "", + contentType = "", + category = "", + description = "", + tags = emptyList() + ) + } + return try { + val node = objectMapper.readTree(dbData) + val title = node.get("title")?.asText() ?: "" + val contentType = node.get("contentType")?.asText() ?: "" + val category = node.get("category")?.asText() ?: "" + val description = node.get("description")?.asText() ?: "" + val tagsNode = node.get("tags") + val tags: List = when { + tagsNode == null || tagsNode.isNull -> emptyList() + tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() } + tagsNode.isTextual -> tagsNode.asText() + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + else -> emptyList() + } + OriginalWorkTranslationPayload( + title = title, + contentType = contentType, + category = category, + description = description, + tags = tags + ) + } catch (_: Exception) { + OriginalWorkTranslationPayload( + title = "", + contentType = "", + category = "", + description = "", + tags = emptyList() + ) + } + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt new file mode 100644 index 00000000..e63fbebb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.chat.original.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface OriginalWorkTranslationRepository : JpaRepository { + fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index b111de7c..9a950027 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository @@ -32,7 +33,8 @@ enum class LanguageDetectTargetType { CHARACTER, CHARACTER_COMMENT, CREATOR_CHEERS, - SERIES + SERIES, + ORIGINAL_WORK } class LanguageDetectEvent( @@ -53,6 +55,7 @@ class LanguageDetectListener( private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, private val seriesRepository: ContentSeriesRepository, + private val originalWorkRepository: OriginalWorkRepository, private val applicationEventPublisher: ApplicationEventPublisher, @@ -85,6 +88,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) + LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event) } } @@ -298,6 +302,45 @@ class LanguageDetectListener( ) } + private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) { + val originalWorkId = event.id + + val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId) + if (originalWork == null) { + log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!originalWork.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}", + originalWorkId, + originalWork.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return + + originalWork.languageCode = langCode + originalWorkRepository.save(originalWork) + + // 언어 감지가 완료된 후 언어 번역 이벤트 호출 + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = originalWorkId, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}", + originalWorkId, + langCode + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index 67cdb64c..30796a0c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -8,6 +8,10 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository @@ -35,7 +39,9 @@ enum class LanguageTranslationTargetType { CONTENT_THEME, SERIES, - SERIES_GENRE + SERIES_GENRE, + + ORIGINAL_WORK } class LanguageTranslationEvent( @@ -50,12 +56,14 @@ class LanguageTranslationListener( private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val seriesRepository: AdminContentSeriesRepository, private val seriesGenreRepository: AdminContentSeriesGenreRepository, + private val originalWorkRepository: OriginalWorkRepository, private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, + private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, private val translationService: PapagoTranslationService ) { @@ -69,6 +77,7 @@ class LanguageTranslationListener( LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) + LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event) } } @@ -364,4 +373,84 @@ class LanguageTranslationListener( } } } + + private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) { + val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return + val languageCode = originalWork.languageCode + if (languageCode != null) return + + /** + * handleSeriesLanguageTranslation 참조하여 원작 번역 구현 + * + * originalWorkTranslationRepository + * + * 번역대상 + * - title + * - contentType + * - category + * - description + * - tags + */ + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val tagsJoined = originalWork.tagMappings + .mapNotNull { it.tag.tag } + .joinToString(", ") + + val texts = mutableListOf() + texts.add(originalWork.title) + texts.add(originalWork.contentType) + texts.add(originalWork.category) + texts.add(originalWork.description) + texts.add(tagsJoined) + + val sourceLanguage = originalWork.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 translatedContentType = translatedTexts[index++] + val translatedCategory = translatedTexts[index++] + val translatedDescription = translatedTexts[index++] + val translatedTagsJoined = translatedTexts[index] + + val translatedTags = translatedTagsJoined + .split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + + val payload = OriginalWorkTranslationPayload( + title = translatedTitle, + contentType = translatedContentType, + category = translatedCategory, + description = translatedDescription, + tags = translatedTags + ) + + val existing = originalWorkTranslationRepository + .findByOriginalWorkIdAndLocale(originalWork.id!!, locale) + + if (existing == null) { + originalWorkTranslationRepository.save( + OriginalWorkTranslation( + originalWorkId = originalWork.id!!, + locale = locale, + renderedPayload = payload + ) + ) + } else { + existing.renderedPayload = payload + originalWorkTranslationRepository.save(existing) + } + } + } + } } -- 2.49.1 From 0c52804f066ae99ff7fc2f0623590f33a590f430 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 06:19:15 +0900 Subject: [PATCH 58/90] =?UTF-8?q?=EC=9B=90=EC=9E=91=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=98=20=EC=A0=9C=EB=AA=A9=EA=B3=BC=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=ED=83=80=EC=9E=85=EC=9D=84=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4(locale)=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EB=B2=88=EC=97=AD=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OriginalWorkController.kt | 58 ++++++++++++++++++- .../OriginalWorkTranslationRepository.kt | 2 + 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index eb8cbfb6..6efc6783 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -7,8 +7,10 @@ import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.springframework.beans.factory.annotation.Value import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -30,6 +32,10 @@ class OriginalWorkController( private val queryService: OriginalWorkQueryService, private val characterImageRepository: CharacterImageRepository, + private val langContext: LangContext, + + private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -51,7 +57,57 @@ class OriginalWorkController( val includeAdult = member?.auth != null val pageRes = queryService.listForAppPage(includeAdult, page, size) val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) } - ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content)) + + /** + * 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + */ + val translatedContent = run { + if (content.isEmpty()) { + content + } else { + val ids = content.map { it.id }.toSet() + val locale = langContext.lang.code + val translations = originalWorkTranslationRepository + .findByOriginalWorkIdInAndLocale(ids, locale) + .associateBy { it.originalWorkId } + + content.map { item -> + val payload = translations[item.id]?.renderedPayload + if (payload != null) { + val newTitle = payload.title.trim() + val newContentType = payload.contentType.trim() + val hasTitle = newTitle.isNotEmpty() + val hasContentType = newContentType.isNotEmpty() + if (hasTitle || hasContentType) { + item.copy( + title = if (hasTitle) newTitle else item.title, + contentType = if (hasContentType) newContentType else item.contentType + ) + } else { + item + } + } else { + item + } + } + } + } + + ApiResponse.ok( + OriginalWorkListResponse( + totalCount = pageRes.totalElements, + content = translatedContent + ) + ) } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt index e63fbebb..0e88a4ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface OriginalWorkTranslationRepository : JpaRepository { fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation? + + fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set, locale: String): List } -- 2.49.1 From 67a8de9e7acc5eda5d48be2c7b56fcac939dec3d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 06:52:48 +0900 Subject: [PATCH 59/90] =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20-=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EB=B0=8F=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=86=8C=EA=B0=9C=20=EC=9D=BC=EA=B4=84=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/OriginalWorkController.kt | 52 +++++++- .../chat/original/dto/OriginalWorkAppDtos.kt | 10 +- .../service/OriginalWorkTranslationService.kt | 124 ++++++++++++++++++ .../translation/OriginalWorkTranslation.kt | 8 ++ 4 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 6efc6783..0cfbdef2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -3,10 +3,12 @@ package kr.co.vividnext.sodalive.chat.original.controller import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException @@ -34,7 +36,9 @@ class OriginalWorkController( private val langContext: LangContext, + private val originalWorkTranslationService: OriginalWorkTranslationService, private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -139,20 +143,56 @@ class OriginalWorkController( emptySet() } - ApiResponse.ok( - OriginalWorkDetailResponse.from( - ow, - imageHost, + val translatedOriginal = originalWorkTranslationService.ensureTranslated( + originalWork = ow, + targetLocale = langContext.lang.code + ) + + /** + * 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + */ + val translatedCharacters = run { + if (chars.isEmpty()) { + emptyList() + } else { + val ids = chars.mapNotNull { it.id } + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(ids, langContext.lang.code) + .associateBy { it.characterId } + chars.map { val path = it.imagePath ?: "profile/default-profile.png" + val tr = translations[it.id!!]?.renderedPayload + val newName = tr?.name?.trim().orEmpty() + val newDesc = tr?.description?.trim().orEmpty() + val hasName = newName.isNotEmpty() + val hasDesc = newDesc.isNotEmpty() Character( characterId = it.id!!, - name = it.name, - description = it.description, + name = if (hasName) newName else it.name, + description = if (hasDesc) newDesc else it.description, imageUrl = "$imageHost/$path", new = recentSet.contains(it.id) ) } + } + } + + ApiResponse.ok( + OriginalWorkDetailResponse.from( + ow, + imageHost, + translatedCharacters, + translated = translatedOriginal ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt index d1a652d8..4b4a29f7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.original.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork /** * 앱용 원작 목록 아이템 응답 DTO @@ -54,13 +55,15 @@ data class OriginalWorkDetailResponse( @JsonProperty("studio") val studio: String?, @JsonProperty("originalLinks") val originalLinks: List, @JsonProperty("tags") val tags: List, - @JsonProperty("characters") val characters: List + @JsonProperty("characters") val characters: List, + @JsonProperty("translated") val translated: TranslatedOriginalWork? ) { companion object { fun from( entity: OriginalWork, imageHost: String = "", - characters: List + characters: List, + translated: TranslatedOriginalWork? ): OriginalWorkDetailResponse { val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { "$imageHost/${entity.imagePath}" @@ -80,7 +83,8 @@ data class OriginalWorkDetailResponse( studio = entity.studio, originalLinks = entity.originalLinks.map { it.url }, tags = entity.tagMappings.map { it.tag.tag }, - characters = characters + characters = characters, + translated = translated ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt new file mode 100644 index 00000000..33749974 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt @@ -0,0 +1,124 @@ +package kr.co.vividnext.sodalive.chat.original.service + +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload +import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository +import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class OriginalWorkTranslationService( + private val translationRepository: OriginalWorkTranslationRepository, + private val papagoTranslationService: PapagoTranslationService +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다. + * - 기존 번역이 있으면 그대로 사용 + * - 없으면 파파고 번역 수행 후 저장 + * - 실패/불필요 시 null 반환 + */ + @Transactional + fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? { + val source = originalWork.languageCode?.lowercase() + val target = targetLocale.lowercase() + + if (source.isNullOrBlank() || source == target) { + return null + } + + // 기존 번역 조회 + val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target) + val existedPayload = existed?.renderedPayload + if (existedPayload != null) { + val t = existedPayload.title.trim() + val ct = existedPayload.contentType.trim() + val cat = existedPayload.category.trim() + val desc = existedPayload.description.trim() + val tags = existedPayload.tags + val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty() + if (hasAny) { + return TranslatedOriginalWork( + title = t, + contentType = ct, + category = cat, + description = desc, + tags = tags + ) + } + } + + // 파파고 번역 수행 + return try { + val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() } + val texts = buildList { + add(originalWork.title) + add(originalWork.contentType) + add(originalWork.category) + add(originalWork.description) + addAll(tags) + } + + val response = papagoTranslationService.translate( + TranslateRequest( + texts = texts, + sourceLanguage = source, + targetLanguage = target + ) + ) + + val out = response.translatedText + if (out.isEmpty()) return null + + // 앞 4개는 필드, 나머지는 태그 + val title = out.getOrNull(0)?.trim().orEmpty() + val contentType = out.getOrNull(1)?.trim().orEmpty() + val category = out.getOrNull(2)?.trim().orEmpty() + val description = out.getOrNull(3)?.trim().orEmpty() + val translatedTags = if (out.size > 4) { + out.drop(4).map { it.trim() }.filter { it.isNotEmpty() } + } else { + emptyList() + } + + val hasAny = title.isNotEmpty() || contentType.isNotEmpty() || + category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty() + if (!hasAny) return null + + val payload = OriginalWorkTranslationPayload( + title = title, + contentType = contentType, + category = category, + description = description, + tags = translatedTags + ) + + val entity = existed?.apply { this.renderedPayload = payload } + ?: OriginalWorkTranslation( + originalWorkId = originalWork.id!!, + locale = target, + renderedPayload = payload + ) + + translationRepository.save(entity) + + TranslatedOriginalWork( + title = title, + contentType = contentType, + category = category, + description = description, + tags = translatedTags + ) + } catch (e: Exception) { + log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message) + null + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt index bdc85332..d5d7b9ce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt @@ -35,6 +35,14 @@ data class OriginalWorkTranslationPayload( val tags: List ) +data class TranslatedOriginalWork( + val title: String, + val contentType: String, + val category: String, + val description: String, + val tags: List +) + @Converter(autoApply = false) class OriginalWorkTranslationPayloadConverter : AttributeConverter { -- 2.49.1 From 68cfa201ebc635aa25452f1ae8747da1c1bc6dbd Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 01:08:56 +0900 Subject: [PATCH 60/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=BD=98=ED=85=90=EC=B8=A0=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EC=96=B8=EC=96=B4=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B2=88=EC=97=AD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/LanguageDetectEvent.kt | 26 +++++++++- .../sodalive/content/category/Category.kt | 3 +- .../content/category/CategoryService.kt | 24 ++++++++- .../content/category/CategoryTranslation.kt | 18 +++++++ .../category/CategoryTranslationRepository.kt | 7 +++ .../translation/LanguageTranslationEvent.kt | 52 ++++++++++++++++++- 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslation.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 9a950027..6d125a20 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.content.category.CategoryRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository @@ -34,7 +35,9 @@ enum class LanguageDetectTargetType { CHARACTER_COMMENT, CREATOR_CHEERS, SERIES, - ORIGINAL_WORK + ORIGINAL_WORK, + + CREATOR_CONTENT_CATEGORY } class LanguageDetectEvent( @@ -56,6 +59,7 @@ class LanguageDetectListener( private val creatorCheersRepository: CreatorCheersRepository, private val seriesRepository: ContentSeriesRepository, private val originalWorkRepository: OriginalWorkRepository, + private val categoryRepository: CategoryRepository, private val applicationEventPublisher: ApplicationEventPublisher, @@ -89,6 +93,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event) + LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event) } } @@ -341,6 +346,25 @@ class LanguageDetectListener( ) } + private fun handleCreatorContentCategoryLanguageDetect(event: LanguageDetectEvent) { + val categoryId = event.id + + val category = categoryRepository.findByIdOrNull(categoryId) ?: return + if (!category.languageCode.isNullOrBlank()) return + + val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return + + category.languageCode = langCode + categoryRepository.save(category) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = categoryId, + targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY + ) + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/Category.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/Category.kt index 6125131f..248c86d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/Category.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/Category.kt @@ -8,9 +8,10 @@ import javax.persistence.JoinColumn import javax.persistence.ManyToOne @Entity -data class Category( +class Category( var title: String, var orders: Int = 1, + var languageCode: String? = null, var isActive: Boolean = true ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt index 00c6c9ae..5c91b2af 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt @@ -2,8 +2,13 @@ package kr.co.vividnext.sodalive.content.category import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,7 +17,9 @@ class CategoryService( private val repository: CategoryRepository, private val contentRepository: AudioContentRepository, private val blockMemberRepository: BlockMemberRepository, - private val categoryContentRepository: CategoryContentRepository + private val categoryContentRepository: CategoryContentRepository, + + private val applicationEventPublisher: ApplicationEventPublisher ) { @Transactional fun createCategory(request: CreateCategoryRequest, member: Member) { @@ -40,6 +47,14 @@ class CategoryService( ) categoryContent.isActive = true } + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = category.id!!, + query = request.title, + targetType = LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY + ) + ) } @Transactional @@ -50,6 +65,13 @@ class CategoryService( if (!request.title.isNullOrBlank()) { validateTitle(title = request.title) category.title = request.title + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.categoryId, + targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY + ) + ) } for (contentId in request.addContentIdList) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslation.kt new file mode 100644 index 00000000..e743c6d5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslation.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.content.category + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["categoryId", "locale"]) + ] +) +class CategoryTranslation( + val categoryId: Long, + val locale: String, + var category: String +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt new file mode 100644 index 00000000..92b88394 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.category + +import org.springframework.data.jpa.repository.JpaRepository + +interface CategoryTranslationRepository : JpaRepository { + fun findByCategoryIdAndLocale(categoryId: Long, locale: String): CategoryTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index 30796a0c..99e241ee 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -13,6 +13,9 @@ import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslatio import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.category.CategoryRepository +import kr.co.vividnext.sodalive.content.category.CategoryTranslation +import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation @@ -41,7 +44,9 @@ enum class LanguageTranslationTargetType { SERIES, SERIES_GENRE, - ORIGINAL_WORK + ORIGINAL_WORK, + + CREATOR_CONTENT_CATEGORY } class LanguageTranslationEvent( @@ -65,6 +70,9 @@ class LanguageTranslationListener( private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, + private val categoryRepository: CategoryRepository, + private val categoryTranslationRepository: CategoryTranslationRepository, + private val translationService: PapagoTranslationService ) { @Async @@ -78,6 +86,7 @@ class LanguageTranslationListener( LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event) + LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageTranslation(event) } } @@ -453,4 +462,45 @@ class LanguageTranslationListener( } } } + + private fun handleCreatorContentCategoryLanguageTranslation(event: LanguageTranslationEvent) { + val category = categoryRepository.findByIdOrNull(event.id) + + if (category == null || !category.isActive || category.languageCode.isNullOrBlank()) return + + val sourceLanguage = category.languageCode ?: "ko" + getTranslatableLanguageCodes(sourceLanguage).forEach { locale -> + val texts = mutableListOf() + texts.add(category.title) + + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = sourceLanguage, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + val translatedCategory = translatedTexts[0] + + val existing = categoryTranslationRepository + .findByCategoryIdAndLocale(category.id!!, locale) + + if (existing == null) { + categoryTranslationRepository.save( + CategoryTranslation( + categoryId = category.id!!, + locale = locale, + category = translatedCategory + ) + ) + } else { + existing.category = translatedCategory + categoryTranslationRepository.save(existing) + } + } + } + } } -- 2.49.1 From 31242a1f7684e508a9726bbb545d005c06d295c2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 02:32:20 +0900 Subject: [PATCH 61/90] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=96=B8?= =?UTF-8?q?=EC=96=B4=EB=B3=84=20=EB=B2=88=EC=97=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LangContext에 따라 카테고리명을 번역해 반환한다. 번역본이 없으면 Papago API로 번역 후 CategoryTranslation에 저장하고 즉시 결과를 반환한다. 공개 API의 getCategoryList 응답이 요청 로케일을 반영한다. --- .../content/category/CategoryService.kt | 82 ++++++++++++++++++- .../category/CategoryTranslationRepository.kt | 2 + 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt index 5c91b2af..1fc15860 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt @@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.LanguageDetectEvent import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +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 org.springframework.context.ApplicationEventPublisher @@ -18,8 +21,11 @@ class CategoryService( private val contentRepository: AudioContentRepository, private val blockMemberRepository: BlockMemberRepository, private val categoryContentRepository: CategoryContentRepository, + private val categoryTranslationRepository: CategoryTranslationRepository, - private val applicationEventPublisher: ApplicationEventPublisher + private val langContext: LangContext, + private val applicationEventPublisher: ApplicationEventPublisher, + private val translationService: PapagoTranslationService ) { @Transactional fun createCategory(request: CreateCategoryRequest, member: Member) { @@ -119,11 +125,83 @@ class CategoryService( } } + @Transactional fun getCategoryList(creatorId: Long, memberId: Long): List { val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) if (isBlocked) throw SodaException("잘못된 접근입니다.") - return repository.findByCreatorId(creatorId = creatorId) + // 기본 카테고리 목록 조회 (원본 언어 기준) + val baseList = repository.findByCreatorId(creatorId = creatorId) + if (baseList.isEmpty()) return baseList + + val locale = langContext.lang.code + + // 원본 엔티티를 조회하여 languageCode 파악 + val categoryIds = baseList.map { it.categoryId } + val entities = repository.findAllById(categoryIds) + val entityMap = entities.associateBy { it.id!! } + + // 요청 로케일로 이미 저장된 번역 일괄 조회 + val translations = categoryTranslationRepository + .findByCategoryIdInAndLocale(categoryIds, locale) + .associateBy { it.categoryId } + + // 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용 + val result = mutableListOf() + for (item in baseList) { + val entity = entityMap[item.categoryId] + if (entity == null) { + result.add(item) + continue + } + + val sourceLang = entity.languageCode + if (!sourceLang.isNullOrBlank() && sourceLang != locale) { + val existing = translations[item.categoryId] + if (existing != null && !existing.category.isNullOrBlank()) { + result.add(GetCategoryListResponse(categoryId = item.categoryId, category = existing.category)) + continue + } + + // 번역본이 없으면 Papago 번역 후 저장 + val texts = listOf(entity.title) + val response = translationService.translate( + request = TranslateRequest( + texts = texts, + sourceLanguage = sourceLang, + targetLanguage = locale + ) + ) + + val translatedTexts = response.translatedText + if (translatedTexts.size == texts.size) { + val translatedCategory = translatedTexts[0] + + val existingOne = categoryTranslationRepository + .findByCategoryIdAndLocale(entity.id!!, locale) + if (existingOne == null) { + categoryTranslationRepository.save( + CategoryTranslation( + categoryId = entity.id!!, + locale = locale, + category = translatedCategory + ) + ) + } else { + existingOne.category = translatedCategory + categoryTranslationRepository.save(existingOne) + } + + result.add(GetCategoryListResponse(categoryId = item.categoryId, category = translatedCategory)) + continue + } + } + + // 번역이 필요 없거나 실패한 경우 원본 사용 + result.add(item) + } + + return result } private fun validateTitle(title: String) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt index 92b88394..a99f61d2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface CategoryTranslationRepository : JpaRepository { fun findByCategoryIdAndLocale(categoryId: Long, locale: String): CategoryTranslation? + + fun findByCategoryIdInAndLocale(categoryIds: Collection, locale: String): List } -- 2.49.1 From 6cc15a87487d99e299dd0b4960d6fe6753af3dd6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 03:23:56 +0900 Subject: [PATCH 62/90] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=ED=85=8C=EB=A7=88=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오디오 콘텐츠 목록 응답에서 테마 문자열에 번역을 적용한다. 번역 데이터가 없을 때는 기존 원문을 유지한다. --- AGENTS.md | 37 +++++++++++ .../sodalive/content/AudioContentService.kt | 61 ++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8a8b9224 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +> 이 문서는 본 저장소에서 **AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙)**이다. +> 모든 신규 코드는 본 문서를 최우선 기준으로 작성한다. + +--- + +## 0. 전제 +질문에 대한 답변과 설명은 한국어로 한다. + +--- + +## 15. Commit Standards + +1. 커밋 메시지는 **반드시 한국어로 작성한다.** +2. 제목(subject)은 **현재형**으로 작성한다. (예: “기능 추가”, “기능 추가함” 금지) +3. 제목은 **50자 이내**로 작성한다. +4. 제목과 본문 사이에는 **반드시 한 줄 공백**을 둔다. +5. 본문은 **한 줄당 72자 이내**로 작성한다. +6. 하나의 문단에서는 72자를 초과할 때만 줄바꿈한다. +7. **공개 API 변경 사항만 설명**하며, 패키지 프라이빗 구현 상세는 포함하지 않는다. +8. **테스트 코드에 대한 언급은 커밋 메시지에 포함하지 않는다.** +9. 제목에 `fix:`, `feat:`, `docs:` 등의 **접두어를 사용하지 않는다.** +10. 제목은 **첫 글자를 대문자로 시작**한다. 단, 함수명 등 소문자가 합당한 경우만 예외를 허용한다. +11. 도구 광고, 브랜딩, 홍보성 문구를 **절대 포함하지 않는다.** +12. 커밋 전에는 **반드시 파일을 개별 stage 한다.** +13. 커밋 전 **`work/scripts/check-commit-message-rules.sh` 검증을 통과해야 한다.** + +--- + +## 16. AI 사용 규칙 (AI Interaction Rules) + +- 매우 작은 단위의 변경만 수행한다. +- 대규모 리팩터링은 반드시 사전 승인을 요청한다. + +--- + +✅ 본 문서는 **AI와 사람이 동일한 개발 규칙을 공유하기 위한 최상위 기준 문서**이며, +✅ 모든 신규 코드는 본 문서를 기준으로 검토된다. 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 d3116a3c..27a71315 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -920,12 +920,34 @@ class AudioContentService( contentId = audioContent.id!! ) + /** + * themeStr 번역 처리 + */ + val themeStrTranslated = run { + val theme = audioContent.theme + if (theme?.id != null) { + val locale = langContext.lang.code + val translated = contentThemeTranslationRepository + .findByContentThemeIdAndLocale(theme.id!!, locale) + val text = translated?.theme + if (!text.isNullOrBlank()) text else theme.theme + } else { + audioContent.theme!!.theme + } + } + return GetAudioContentListItem( contentId = audioContent.id!!, coverImageUrl = "$coverImageHost/${audioContent.coverImage}", - title = audioContent.title, + title = run { + val translatedTitle = contentTranslationRepository + .findByContentIdAndLocale(audioContent.id!!, langContext.lang.code) + ?.renderedPayload + ?.title + if (translatedTitle.isNullOrBlank()) audioContent.title else translatedTitle + }, price = audioContent.price, - themeStr = audioContent.theme!!.theme, + themeStr = themeStrTranslated, duration = audioContent.duration, likeCount = likeCount, commentCount = commentCount, @@ -1017,9 +1039,42 @@ class AudioContentService( items } + // theme 번역 적용: 번역 데이터가 있으면 번역, 없으면 원문 유지 + val themeTranslatedList = run { + if (translatedContentList.isEmpty()) { + translatedContentList + } else { + val locale = langContext.lang.code + + // 활성 테마 목록에서 한글 원문 -> ID 매핑 구성 + val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent( + isAdult = isAdult, + isFree = false, + isPointAvailableOnly = false, + contentType = contentType + ) + val idByKorean = themesWithIds.associate { it.theme to it.id } + + val themeIds = idByKorean.values.distinct() + val translatedById = if (themeIds.isNotEmpty()) { + contentThemeTranslationRepository + .findByContentThemeIdInAndLocale(themeIds, locale) + .associate { it.contentThemeId to it.theme } + } else { + emptyMap() + } + + translatedContentList.map { item -> + val themeId = idByKorean[item.themeStr] + val translated = if (themeId != null) translatedById[themeId] else null + if (!translated.isNullOrBlank()) item.copy(themeStr = translated) else item + } + } + } + return GetAudioContentListResponse( totalCount = totalCount, - items = translatedContentList + items = themeTranslatedList ) } -- 2.49.1 From 3f74eefacc6cfef1b561ecb2f62ecd80a0c0ae6b Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 12:26:04 +0900 Subject: [PATCH 63/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=BB=A4=EB=AE=A4=EB=8B=88=ED=8B=B0=EC=97=90=20utc?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creatorCommunity/CreatorCommunity.kt | 2 ++ .../creatorCommunity/CreatorCommunityService.kt | 17 +++++++++++++++++ .../GetCommunityPostListResponse.kt | 1 + .../SelectCommunityPostResponse.kt | 2 ++ 4 files changed, 22 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt index e22cee70..dd57df8b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt @@ -31,6 +31,7 @@ data class CreatorCommunity( audioUrl: String?, content: String, date: String, + dateUtc: String, isLike: Boolean, existOrdered: Boolean, likeCount: Int, @@ -51,6 +52,7 @@ data class CreatorCommunity( content = content, price = price, date = date, + dateUtc = dateUtc, isCommentAvailable = isCommentAvailable, isAdult = false, isLike = isLike, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 66989598..b71f6669 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -29,6 +29,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile +import java.time.ZoneId @Service class CreatorCommunityService( @@ -243,6 +244,10 @@ class CreatorCommunityService( imageHost = imageHost, audioUrl = audioUrl, date = it.date.getTimeAgoString(), + dateUtc = it.date + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString(), isLike = isLike, memberId = memberId, existOrdered = if (memberId == it.creatorId) { @@ -314,6 +319,10 @@ class CreatorCommunityService( imageHost = imageHost, audioUrl = audioUrl, date = post.date.getTimeAgoString(), + dateUtc = post.date + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString(), isLike = isLike, memberId = memberId, existOrdered = if (memberId == post.creatorId) { @@ -494,6 +503,10 @@ class CreatorCommunityService( imageHost = imageHost, audioUrl = null, date = it.date.getTimeAgoString(), + dateUtc = it.date + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString(), isLike = isLike, memberId = memberId, existOrdered = if (memberId == it.creatorId) { @@ -578,6 +591,10 @@ class CreatorCommunityService( audioUrl = audioUrl, content = post.content, date = post.createdAt!!.getTimeAgoString(), + dateUtc = post.createdAt!! + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString(), isLike = isLike, existOrdered = true, likeCount = likeCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt index 81a5f226..7153f809 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/GetCommunityPostListResponse.kt @@ -13,6 +13,7 @@ data class GetCommunityPostListResponse @QueryProjection constructor( val content: String, val price: Int, val date: String, + val dateUtc: String, val isCommentAvailable: Boolean, val isAdult: Boolean, val isLike: Boolean, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt index c2900673..df6c657a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt @@ -21,6 +21,7 @@ data class SelectCommunityPostResponse @QueryProjection constructor( imageHost: String, audioUrl: String?, date: String, + dateUtc: String, isLike: Boolean, memberId: Long, existOrdered: Boolean, @@ -57,6 +58,7 @@ data class SelectCommunityPostResponse @QueryProjection constructor( }, price = price, date = date, + dateUtc = dateUtc, isCommentAvailable = isCommentAvailable, isAdult = isAdult, isLike = isLike, -- 2.49.1 From ee495dae3a941c169ae4fa8685f4e45a48b67842 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 12:27:32 +0900 Subject: [PATCH 64/90] =?UTF-8?q?translated=EB=9D=BC=EB=8A=94=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=B4=20=EC=A4=91=EB=B3=B5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EC=96=B4=20=EC=83=9D=EA=B8=B0=EB=8D=98=20name=20shado?= =?UTF-8?q?w=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/vividnext/sodalive/content/AudioContentService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 27a71315..15be2f77 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -835,9 +835,9 @@ class AudioContentService( val theme = audioContent.theme if (theme?.id != null) { val locale = langContext.lang.code - val translated = contentThemeTranslationRepository + val translatedContentTheme = contentThemeTranslationRepository .findByContentThemeIdAndLocale(theme.id!!, locale) - val text = translated?.theme + val text = translatedContentTheme?.theme if (!text.isNullOrBlank()) text else theme.theme } else { audioContent.theme!!.theme -- 2.49.1 From 4a4dbccc0d81108d9be012fc7a3cf73b02ce50bc Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 23:38:46 +0900 Subject: [PATCH 65/90] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=9D=91=EB=8B=B5=EC=97=90=20dateUtc=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../live/room/GetLatestFinishedLiveResponse.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt index e242ecb0..70251422 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/GetLatestFinishedLiveResponse.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import com.querydsl.core.annotations.QueryProjection import kr.co.vividnext.sodalive.extensions.getTimeAgoString import java.time.LocalDateTime +import java.time.ZoneId data class GetLatestFinishedLiveQueryResponse @QueryProjection constructor( val memberId: Long, @@ -16,12 +17,17 @@ data class GetLatestFinishedLiveResponse( @JsonProperty("memberId") val memberId: Long, @JsonProperty("nickname") val nickname: String, @JsonProperty("profileImageUrl") val profileImageUrl: String, - @JsonProperty("timeAgo") val timeAgo: String + @JsonProperty("timeAgo") val timeAgo: String, + @JsonProperty("dateUtc") val dateUtc: String ) { constructor(response: GetLatestFinishedLiveQueryResponse) : this( response.memberId, response.nickname, response.profileImageUrl, - response.updatedAt.getTimeAgoString() + response.updatedAt.getTimeAgoString(), + response.updatedAt + .atZone(ZoneId.of("UTC")) + .toInstant() + .toString() ) } -- 2.49.1 From 6fa0667120b8f4e8f1124c4763453c3ce49be931 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Dec 2025 23:54:25 +0900 Subject: [PATCH 66/90] =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=20=EC=96=B8=EC=96=B4=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=EB=AA=85=20=EB=B2=88=EC=97=AD?= =?UTF-8?q?=EC=9D=84=20=EC=A0=81=EC=9A=A9=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=B4=20=EC=B6=94=EA=B0=80=EB=90=98=EC=96=B4,=20?= =?UTF-8?q?=EC=98=81=EC=96=B4=EC=99=80=20=EC=9D=BC=EB=B3=B8=EC=96=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=8B=9C=20=EB=B2=88=EC=97=AD=EB=90=9C=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=EB=AA=85=EC=9D=B4=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/room/service/ChatRoomService.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index abd21c44..bf745f2c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.chat.room.ChatMessageType @@ -26,6 +27,8 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -50,6 +53,8 @@ class ChatRoomService( private val messageRepository: ChatMessageRepository, private val characterService: ChatCharacterService, private val characterImageService: CharacterImageService, + private val langContext: LangContext, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, @@ -270,10 +275,22 @@ class ChatRoomService( val time = latest?.createdAt ?: q.lastActivityAt val timeLabel = formatRelativeTime(time) + // 언어 컨텍스트(en/ja)에서 번역본이 존재하면 번역된 캐릭터명을 사용 + val localizedTitle = when (langContext.lang) { + Lang.EN, Lang.JA -> { + val tr = aiCharacterTranslationRepository + .findByCharacterIdAndLocale(q.characterId, langContext.lang.code) + val name = tr?.renderedPayload?.name + if (!name.isNullOrBlank()) name else q.title + } + + else -> q.title + } + ChatRoomListItemDto( chatRoomId = q.chatRoomId, characterId = q.characterId, - title = q.title, + title = localizedTitle, imageUrl = imageUrl, opponentType = opponentType, lastMessagePreview = preview, -- 2.49.1 From ff1b8aa4131cdeeac9616de0dee327cb5802cc53 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 16:39:06 +0900 Subject: [PATCH 67/90] =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=82=A4=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/room/service/ChatRoomService.kt | 22 ++++----- .../sodalive/common/SodaException.kt | 11 ++++- .../sodalive/common/SodaExceptionHandler.kt | 35 +++++++++---- .../sodalive/i18n/SodaMessageSource.kt | 49 +++++++++++++++++++ .../sodalive/member/MemberController.kt | 20 ++++---- 5 files changed, 104 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index bf745f2c..3a47432c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -316,11 +316,9 @@ class ChatRoomService( @Transactional(readOnly = true) fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") - val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - if (participant == null) { - throw SodaException("잘못된 접근입니다") - } + ?: throw SodaException(messageKey = "chat.error.room_not_found") + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException(messageKey = "common.error.access_denied") return fetchSessionActive(room.sessionId) } @@ -328,7 +326,7 @@ class ChatRoomService( fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse { // 1) 활성 여부 무관하게 방 조회 val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") + SodaException(messageKey = "chat.error.room_not_found") } // 2) 기본 방 기준 참여/활성 여부 확인 @@ -342,10 +340,10 @@ class ChatRoomService( ParticipantType.CHARACTER ) ?: baseRoom.participants.firstOrNull { it.participantType == ParticipantType.CHARACTER - } ?: throw SodaException("잘못된 접근입니다") + } ?: throw SodaException(messageKey = "common.error.invalid_request") val baseCharacter = baseCharacterParticipant.character - ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "common.error.unknown") // 4) 유효한 입장 대상 방 결정 val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) { @@ -355,9 +353,9 @@ class ChatRoomService( val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter) alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리 if (!isActiveRoom) { - throw SodaException("채팅방을 찾을 수 없습니다.") + throw SodaException(messageKey = "chat.error.room_not_found") } else { - throw SodaException("잘못된 접근입니다") + throw SodaException(messageKey = "common.error.invalid_request") } ) } @@ -368,10 +366,10 @@ class ChatRoomService( ParticipantType.CHARACTER ) ?: effectiveRoom.participants.firstOrNull { it.participantType == ParticipantType.CHARACTER - } ?: throw SodaException("잘못된 접근입니다") + } ?: throw SodaException(messageKey = "common.error.invalid_request") val character = characterParticipant.character - ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "common.error.unknown") val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}" val characterDto = ChatRoomEnterCharacterDto( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt index 261e4812..86046fdc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaException.kt @@ -1,5 +1,12 @@ package kr.co.vividnext.sodalive.common -class SodaException(message: String, val errorProperty: String? = null) : RuntimeException(message) +class SodaException( + message: String? = null, + val errorProperty: String? = null, + val messageKey: String? = null +) : RuntimeException(message) -class AdsChargeException(message: String) : RuntimeException(message) +class AdsChargeException( + message: String? = null, + val messageKey: String? = null +) : RuntimeException(message) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt index 74e1396f..443e58cc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.common +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import org.slf4j.LoggerFactory import org.springframework.dao.DataIntegrityViolationException import org.springframework.http.HttpStatus @@ -13,14 +15,20 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException import org.springframework.web.server.ResponseStatusException @RestControllerAdvice -class SodaExceptionHandler { +class SodaExceptionHandler( + private val langContext: LangContext, + private val messageSource: SodaMessageSource +) { private val logger = LoggerFactory.getLogger(this::class.java) @ExceptionHandler(SodaException::class) fun handleSodaException(e: SodaException) = run { logger.error("API error", e) + val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) } + ?: e.message?.takeIf { it.isNotBlank() } + ?: messageSource.getMessage("common.error.unknown", langContext.lang) ApiResponse.error( - message = e.message, + message = message, errorProperty = e.errorProperty ) } @@ -28,44 +36,53 @@ class SodaExceptionHandler { @ExceptionHandler(MaxUploadSizeExceededException::class) fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run { logger.error("API error", e) - ApiResponse.error(message = "파일용량은 최대 1024MB까지 저장할 수 있습니다.") + val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang) + ApiResponse.error(message = message) } @ExceptionHandler(AccessDeniedException::class) fun handleAccessDeniedException(e: AccessDeniedException) = run { logger.error("API error", e) - ApiResponse.error(message = "권한이 없습니다.") + val message = messageSource.getMessage("common.error.access_denied", langContext.lang) + ApiResponse.error(message = message) } @ExceptionHandler(InternalAuthenticationServiceException::class) fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run { logger.error("API error", e) - ApiResponse.error("로그인 정보를 확인해주세요.") + val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang) + ApiResponse.error(message) } @ExceptionHandler(BadCredentialsException::class) fun handleBadCredentialsException(e: BadCredentialsException) = run { logger.error("API error", e) - ApiResponse.error("로그인 정보를 확인해주세요.") + val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang) + ApiResponse.error(message) } @ExceptionHandler(DataIntegrityViolationException::class) fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run { logger.error("API error", e) - ApiResponse.error("이미 등록되어 있습니다.") + val message = messageSource.getMessage("common.error.already_registered", langContext.lang) + ApiResponse.error(message) } @ResponseStatus(value = HttpStatus.NOT_FOUND) @ExceptionHandler(AdsChargeException::class) fun handleAdsChargeException(e: AdsChargeException) = run { logger.error("API error - AdsChargeException ::: ", e) - ApiResponse.error("잘못된 요청입니다.") + val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) } + ?: e.message?.takeIf { it.isNotBlank() } + ?: messageSource.getMessage("common.error.invalid_request", langContext.lang) + ApiResponse.error(message) } @ExceptionHandler(Exception::class) fun handleException(e: Exception) = run { if (e is ResponseStatusException) throw e logger.error("API error", e) - ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.") + val message = messageSource.getMessage("common.error.unknown", langContext.lang) + ApiResponse.error(message) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt new file mode 100644 index 00000000..2cf5da17 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.i18n + +import org.springframework.stereotype.Component + +@Component +class SodaMessageSource { + private val messages = mapOf( + "common.error.unknown" to mapOf( + Lang.KO to "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + Lang.EN to "An unknown error occurred. Please try again.", + Lang.JA to "不明なエラーが発生しました。もう一度やり直してください。" + ), + "common.error.access_denied" to mapOf( + Lang.KO to "권한이 없습니다.", + Lang.EN to "You do not have permission.", + Lang.JA to "権限がありません。" + ), + "common.error.bad_credentials" to mapOf( + Lang.KO to "로그인 정보를 확인해주세요.", + Lang.EN to "Please check your login information.", + Lang.JA to "ログイン情報を確認してください。" + ), + "common.error.max_upload_size" to mapOf( + Lang.KO to "파일용량은 최대 1024MB까지 저장할 수 있습니다.", + Lang.EN to "The file size can be saved up to 1024MB.", + Lang.JA to "ファイル容量は最大1024MBまで保存できます。" + ), + "common.error.already_registered" to mapOf( + Lang.KO to "이미 등록되어 있습니다.", + Lang.EN to "It is already registered.", + Lang.JA to "すでに登録されています。" + ), + "common.error.invalid_request" to mapOf( + Lang.KO to "잘못된 요청입니다.", + Lang.EN to "Invalid request.", + Lang.JA to "不正なリクエストです。" + ), + "chat.error.room_not_found" to mapOf( + Lang.KO to "채팅방을 찾을 수 없습니다.", + Lang.EN to "Chat room not found.", + Lang.JA to "チャットルームが見つかりません。" + ) + ) + + fun getMessage(key: String, lang: Lang): String? { + val translations = messages[key] ?: return null + return translations[lang] ?: translations[Lang.KO] + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index a2716e06..e6a094a5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -98,7 +98,7 @@ class MemberController( @RequestHeader("Authorization") token: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.logout(token.removePrefix("Bearer "), member.id!!)) } @@ -107,7 +107,7 @@ class MemberController( fun logoutAll( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.logoutAll(member.id!!)) } @@ -117,7 +117,7 @@ class MemberController( @RequestParam container: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getMember(member.id!!, container)) } @@ -127,7 +127,7 @@ class MemberController( @RequestParam container: String?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getMemberInfo(member, container ?: "web")) } @@ -137,7 +137,7 @@ class MemberController( @RequestBody request: UpdateNotificationSettingRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.updateNotificationSettings(request, member)) } @@ -147,7 +147,7 @@ class MemberController( @RequestBody pushTokenUpdateRequest: PushTokenUpdateRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.updatePushToken( @@ -163,7 +163,7 @@ class MemberController( @RequestBody request: MarketingInfoUpdateRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val memberId = member.id!! val marketingPid = service.updateMarketingInfo( @@ -188,7 +188,7 @@ class MemberController( @RequestBody request: AdidUpdateRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.updateAdid( @@ -203,7 +203,7 @@ class MemberController( @RequestParam container: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getMyPage(member, container)) } @@ -213,7 +213,7 @@ class MemberController( @RequestBody request: CreatorFollowRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.creatorFollow( -- 2.49.1 From 8785741855d5ee2098fbefc81a00f04d5c9cfb15 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 18:54:10 +0900 Subject: [PATCH 68/90] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=85=98=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/audition/AdminAuditionService.kt | 15 ++++-- .../admin/audition/CreateAuditionRequest.kt | 4 +- .../AdminAuditionApplicantService.kt | 2 +- .../sodalive/i18n/SodaMessageSource.kt | 54 +++++++++++++++++-- 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt index 3f3a30bc..e362f4f8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/AdminAuditionService.kt @@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -22,6 +24,8 @@ class AdminAuditionService( private val repository: AdminAuditionRepository, private val roleRepository: AdminAuditionRoleRepository, private val applicationEventPublisher: ApplicationEventPublisher, + private val langContext: LangContext, + private val messageSource: SodaMessageSource, @Value("\${cloud.aws.s3.bucket}") private val bucket: String @@ -44,7 +48,7 @@ class AdminAuditionService( fun updateAudition(image: MultipartFile?, requestString: String) { val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java) val audition = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "admin.audition.invalid_request_retry") if (request.title != null) { audition.title = request.title @@ -63,7 +67,7 @@ class AdminAuditionService( (audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) && request.status == AuditionStatus.NOT_STARTED ) { - throw SodaException("모집전 상태로 변경할 수 없습니다.") + throw SodaException(messageKey = "admin.audition.status_cannot_revert") } audition.status = request.status @@ -88,11 +92,14 @@ class AdminAuditionService( } if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) { + val title = messageSource.getMessage("admin.audition.fcm.title.new", langContext.lang).orEmpty() + val messageTemplate = messageSource.getMessage("admin.audition.fcm.message.new", langContext.lang).orEmpty() + val message = String.format(messageTemplate, audition.title) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.IN_PROGRESS_AUDITION, - title = "새로운 오디션 등록!", - message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!", + title = title, + message = message, isAuth = audition.isAdult, auditionId = audition.id ?: -1 ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/CreateAuditionRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/CreateAuditionRequest.kt index ccf7a108..666625f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/CreateAuditionRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/CreateAuditionRequest.kt @@ -11,11 +11,11 @@ data class CreateAuditionRequest( ) { init { if (title.isBlank()) { - throw SodaException("오디션 제목을 입력하세요") + throw SodaException(messageKey = "admin.audition.title_required") } if (information.isBlank() || information.length < 10) { - throw SodaException("오디션 정보는 최소 10글자 입니다") + throw SodaException(messageKey = "admin.audition.information_min_length") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/applicant/AdminAuditionApplicantService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/applicant/AdminAuditionApplicantService.kt index 899c286f..40027e93 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/applicant/AdminAuditionApplicantService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/applicant/AdminAuditionApplicantService.kt @@ -10,7 +10,7 @@ class AdminAuditionApplicantService(private val repository: AdminAuditionApplica @Transactional fun deleteAuditionApplicant(id: Long) { val applicant = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") applicant.isActive = false } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 2cf5da17..be4bf5fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -4,7 +4,7 @@ import org.springframework.stereotype.Component @Component class SodaMessageSource { - private val messages = mapOf( + private val commonMessages = mapOf( "common.error.unknown" to mapOf( Lang.KO to "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", Lang.EN to "An unknown error occurred. Please try again.", @@ -42,8 +42,56 @@ class SodaMessageSource { ) ) + private val auditionMessages = mapOf( + "admin.audition.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request. Please try again.", + Lang.JA to "不正なリクエストです。もう一度やり直してください。" + ), + "admin.audition.status_cannot_revert" to mapOf( + Lang.KO to "모집전 상태로 변경할 수 없습니다.", + Lang.EN to "Cannot change to not-started status.", + Lang.JA to "募集前の状態に変更できません。" + ) + ) + + private val auditionRequestMessages = mapOf( + "admin.audition.title_required" to mapOf( + Lang.KO to "오디션 제목을 입력하세요", + Lang.EN to "Please enter an audition title.", + Lang.JA to "オーディションのタイトルを入力してください。" + ), + "admin.audition.information_min_length" to mapOf( + Lang.KO to "오디션 정보는 최소 10글자 입니다", + Lang.EN to "Audition information must be at least 10 characters.", + Lang.JA to "オーディション情報は最低10文字です。" + ) + ) + + private val auditionNotificationMessages = mapOf( + "admin.audition.fcm.title.new" to mapOf( + Lang.KO to "새로운 오디션 등록!", + Lang.EN to "New audition posted!", + Lang.JA to "新しいオーディション登録!" + ), + "admin.audition.fcm.message.new" to mapOf( + Lang.KO to "'%s'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!", + Lang.EN to "'%s' is now available. Apply for the original audio drama audition now!", + Lang.JA to "「%s」が登録されました。今すぐオリジナルオーディオドラマのオーディションに応募してみてください!" + ) + ) + fun getMessage(key: String, lang: Lang): String? { - val translations = messages[key] ?: return null - return translations[lang] ?: translations[Lang.KO] + val messageGroups = listOf( + commonMessages, + auditionMessages, + auditionRequestMessages, + auditionNotificationMessages + ) + for (messages in messageGroups) { + val translations = messages[key] ?: continue + return translations[lang] ?: translations[Lang.KO] + } + return null } } -- 2.49.1 From 14d0ae985189ec7dfb207f1498d3cdb40e6c37f0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 21:49:07 +0900 Subject: [PATCH 69/90] =?UTF-8?q?=EC=98=A4=EB=94=94=EC=85=98=20=EB=B0=B0?= =?UTF-8?q?=EC=97=AD=20=EB=93=B1=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audition/role/AdminAuditionRoleService.kt | 12 ++- .../role/CreateAuditionRoleRequest.kt | 8 +- .../role/UpdateAuditionRoleRequest.kt | 2 +- .../ratio/CreatorSettlementRatioService.kt | 12 +-- .../sodalive/admin/can/AdminCanService.kt | 10 +-- .../sodalive/i18n/SodaMessageSource.kt | 74 ++++++++++++++++++- 6 files changed, 97 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/AdminAuditionRoleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/AdminAuditionRoleService.kt index 5e5b24a0..f1a4b080 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/AdminAuditionRoleService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/AdminAuditionRoleService.kt @@ -31,7 +31,7 @@ class AdminAuditionRoleService( auditionScriptUrl = request.auditionScriptUrl ) val audition = auditionRepository.findByIdOrNull(id = request.auditionId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "admin.audition.invalid_request_retry") auditionRole.audition = audition repository.save(auditionRole) @@ -48,15 +48,19 @@ class AdminAuditionRoleService( fun updateAuditionRole(image: MultipartFile?, requestString: String) { val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java) val auditionRole = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "admin.audition.invalid_request_retry") if (!request.name.isNullOrBlank()) { - if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다") + if (request.name.length < 2) { + throw SodaException(messageKey = "admin.audition.role.name_min_length") + } auditionRole.name = request.name } if (!request.information.isNullOrBlank()) { - if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다") + if (request.information.length < 10) { + throw SodaException(messageKey = "admin.audition.role.information_min_length") + } auditionRole.information = request.information } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/CreateAuditionRoleRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/CreateAuditionRoleRequest.kt index 1332e8f2..39c8d28f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/CreateAuditionRoleRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/CreateAuditionRoleRequest.kt @@ -10,19 +10,19 @@ data class CreateAuditionRoleRequest( ) { init { if (auditionId < 0) { - throw SodaException("캐릭터가 등록될 오디션을 선택하세요") + throw SodaException(messageKey = "admin.audition.role.audition_required") } if (name.isBlank() || name.length < 2) { - throw SodaException("캐릭터명을 입력하세요") + throw SodaException(messageKey = "admin.audition.role.name_required") } if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) { - throw SodaException("오디션 대본 URL을 입력하세요") + throw SodaException(messageKey = "admin.audition.role.script_url_required") } if (information.isBlank() || information.length < 10) { - throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다") + throw SodaException(messageKey = "admin.audition.role.information_required") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/UpdateAuditionRoleRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/UpdateAuditionRoleRequest.kt index 6454bf0a..5ddf470f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/UpdateAuditionRoleRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/audition/role/UpdateAuditionRoleRequest.kt @@ -13,7 +13,7 @@ data class UpdateAuditionRoleRequest( ) { init { if (id < 0) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt index d114f8c2..b9eddb78 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/ratio/CreatorSettlementRatioService.kt @@ -15,10 +15,10 @@ class CreatorSettlementRatioService( @Transactional fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { val creator = memberRepository.findByIdOrNull(request.memberId) - ?: throw SodaException("잘못된 크리에이터 입니다.") + ?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator") if (creator.role != MemberRole.CREATOR) { - throw SodaException("잘못된 크리에이터 입니다.") + throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator") } val existing = repository.findByMemberId(request.memberId) @@ -43,12 +43,12 @@ class CreatorSettlementRatioService( @Transactional fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) { val creator = memberRepository.findByIdOrNull(request.memberId) - ?: throw SodaException("잘못된 크리에이터 입니다.") + ?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator") if (creator.role != MemberRole.CREATOR) { - throw SodaException("잘못된 크리에이터 입니다.") + throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator") } val existing = repository.findByMemberId(request.memberId) - ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") + ?: throw SodaException(messageKey = "admin.settlement_ratio.not_found") existing.restore() existing.updateValues( request.subsidy, @@ -62,7 +62,7 @@ class CreatorSettlementRatioService( @Transactional fun deleteCreatorSettlementRatio(memberId: Long) { val existing = repository.findByMemberId(memberId) - ?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.") + ?: throw SodaException(messageKey = "admin.settlement_ratio.not_found") existing.softDelete() repository.save(existing) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt index 9e780dc0..9aac7e33 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/can/AdminCanService.kt @@ -33,21 +33,21 @@ class AdminCanService( @Transactional fun deleteCan(id: Long) { val can = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") can.status = CanStatus.END_OF_SALE } @Transactional fun charge(request: AdminCanChargeRequest) { - if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.") - if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.") + if (request.can <= 0) throw SodaException(messageKey = "admin.can.min_amount") + if (request.method.isBlank()) throw SodaException(messageKey = "admin.can.method_required") val ids = request.memberIds.distinct() - if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.") + if (ids.isEmpty()) throw SodaException(messageKey = "admin.can.member_ids_required") val members = memberRepository.findAllById(ids).toList() - if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.") + if (members.size != ids.size) throw SodaException(messageKey = "admin.can.invalid_member_ids") members.forEach { member -> val charge = Charge(0, request.can, status = ChargeStatus.ADMIN) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index be4bf5fd..135fc869 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -81,12 +81,84 @@ class SodaMessageSource { ) ) + private val auditionRoleMessages = mapOf( + "admin.audition.role.name_min_length" to mapOf( + Lang.KO to "배역 이름은 최소 2글자 입니다", + Lang.EN to "Role name must be at least 2 characters.", + Lang.JA to "役名は最低2文字です。" + ), + "admin.audition.role.information_min_length" to mapOf( + Lang.KO to "오디션 배역 정보는 최소 10글자 입니다", + Lang.EN to "Audition role information must be at least 10 characters.", + Lang.JA to "オーディション役の情報は最低10文字です。" + ), + "admin.audition.role.audition_required" to mapOf( + Lang.KO to "캐릭터가 등록될 오디션을 선택하세요", + Lang.EN to "Please select an audition for the character.", + Lang.JA to "キャラクターが登録されるオーディションを選択してください。" + ), + "admin.audition.role.name_required" to mapOf( + Lang.KO to "캐릭터명을 입력하세요", + Lang.EN to "Please enter a character name.", + Lang.JA to "キャラクター名を入力してください。" + ), + "admin.audition.role.script_url_required" to mapOf( + Lang.KO to "오디션 대본 URL을 입력하세요", + Lang.EN to "Please enter the audition script URL.", + Lang.JA to "オーディション台本のURLを入力してください。" + ), + "admin.audition.role.information_required" to mapOf( + Lang.KO to "오디션 캐릭터 정보는 최소 10글자 입니다", + Lang.EN to "Audition character information must be at least 10 characters.", + Lang.JA to "オーディションキャラクター情報は最低10文字です。" + ) + ) + + private val settlementRatioMessages = mapOf( + "admin.settlement_ratio.invalid_creator" to mapOf( + Lang.KO to "잘못된 크리에이터 입니다.", + Lang.EN to "Invalid creator.", + Lang.JA to "不正なクリエイターです。" + ), + "admin.settlement_ratio.not_found" to mapOf( + Lang.KO to "해당 크리에이터의 정산 비율 설정이 없습니다.", + Lang.EN to "Settlement ratio settings not found for this creator.", + Lang.JA to "該当クリエイターの精算比率設定がありません。" + ) + ) + + private val adminCanMessages = mapOf( + "admin.can.min_amount" to mapOf( + Lang.KO to "1 캔 이상 입력하세요.", + Lang.EN to "Please enter at least 1 can.", + Lang.JA to "1缶以上入力してください。" + ), + "admin.can.method_required" to mapOf( + Lang.KO to "기록내용을 입력하세요.", + Lang.EN to "Please enter the record content.", + Lang.JA to "記録内容を入力してください。" + ), + "admin.can.member_ids_required" to mapOf( + Lang.KO to "회원번호를 입력하세요.", + Lang.EN to "Please enter member IDs.", + Lang.JA to "会員番号を入力してください。" + ), + "admin.can.invalid_member_ids" to mapOf( + Lang.KO to "잘못된 회원번호 입니다.", + Lang.EN to "Invalid member IDs.", + Lang.JA to "不正な会員番号です。" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, auditionMessages, auditionRequestMessages, - auditionNotificationMessages + auditionNotificationMessages, + auditionRoleMessages, + settlementRatioMessages, + adminCanMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue -- 2.49.1 From 280b21c3cb687063ac26268d9660c0e5b6c0eca0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 22:30:05 +0900 Subject: [PATCH 70/90] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/AdminChatBannerController.kt | 14 +- .../calculate/AdminChatCalculateService.kt | 6 +- .../character/AdminChatCharacterController.kt | 28 +-- .../CharacterCurationAdminController.kt | 2 +- .../curation/CharacterCurationAdminService.kt | 24 +-- .../image/AdminCharacterImageController.kt | 20 ++- .../service/AdminChatCharacterService.kt | 2 +- .../original/AdminOriginalWorkController.kt | 4 +- .../service/AdminOriginalWorkService.kt | 20 +-- .../sodalive/i18n/SodaMessageSource.kt | 161 +++++++++++++++++- 10 files changed, 231 insertions(+), 50 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 968bc61e..086ab3ed 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize @@ -35,6 +37,8 @@ class AdminChatBannerController( private val bannerService: ChatCharacterBannerService, private val adminCharacterService: AdminChatCharacterService, private val s3Uploader: S3Uploader, + private val langContext: LangContext, + private val messageSource: SodaMessageSource, @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String, @@ -158,8 +162,8 @@ class AdminChatBannerController( filePath = "characters/banners/$bannerId/$fileName", metadata = metadata ) - } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } catch (_: Exception) { + throw SodaException(messageKey = "admin.chat.banner.image_save_failed") } } @@ -208,7 +212,8 @@ class AdminChatBannerController( fun deleteBanner(@PathVariable bannerId: Long) = run { bannerService.deleteBanner(bannerId) - ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") + val message = messageSource.getMessage("admin.chat.banner.delete_success", langContext.lang) + ApiResponse.ok(message) } /** @@ -224,6 +229,7 @@ class AdminChatBannerController( ) = run { bannerService.updateBannerOrders(request.ids) - ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.") + val message = messageSource.getMessage("admin.chat.banner.reorder_success", langContext.lang) + ApiResponse.ok(null, message) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt index 34593e84..d8af09ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/calculate/AdminChatCalculateService.kt @@ -29,13 +29,13 @@ class AdminChatCalculateService( val todayKst = LocalDate.now(kstZone) if (endDate.isAfter(todayKst)) { - throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.") + throw SodaException(messageKey = "admin.chat.calculate.end_date_max_today") } if (startDate.isAfter(endDate)) { - throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.") + throw SodaException(messageKey = "admin.chat.calculate.start_date_after_end") } if (endDate.isAfter(startDate.plusMonths(6))) { - throw SodaException("조회 가능 기간은 최대 6개월입니다.") + throw SodaException(messageKey = "admin.chat.calculate.max_period_6_months") } val startUtc = startDateStr.convertLocalDateTime() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 85c42988..6c3143a5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -124,7 +124,7 @@ class AdminChatCharacterController( // 외부 API 호출 전 DB에 동일한 이름이 있는지 조회 val existingCharacter = service.findByName(request.name) if (existingCharacter != null) { - throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}") + throw SodaException(messageKey = "admin.chat.character.duplicate_name") } // 1. 외부 API 호출 @@ -233,14 +233,18 @@ class AdminChatCharacterController( // success가 false이면 throw if (!apiResponse.success) { - throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.") + val apiMessage = apiResponse.message + if (apiMessage.isNullOrBlank()) { + throw SodaException(messageKey = "admin.chat.character.register_failed_retry") + } + throw SodaException(apiMessage) } // success가 true이면 data.id 반환 - return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.") + return apiResponse.data?.id ?: throw SodaException(messageKey = "admin.chat.character.register_failed_no_id") } catch (e: Exception) { e.printStackTrace() - throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "admin.chat.character.register_failed_retry") } } @@ -257,7 +261,7 @@ class AdminChatCharacterController( metadata = metadata ) } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + throw SodaException(messageKey = "admin.chat.character.image_save_failed") } } @@ -297,19 +301,19 @@ class AdminChatCharacterController( request.originalWorkId != null if (!hasChangedData && !hasImage && !hasDbOnlyChanges) { - throw SodaException("변경된 데이터가 없습니다.") + throw SodaException(messageKey = "admin.chat.character.no_changes") } // 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음) if (hasChangedData) { val chatCharacter = service.findById(request.id) - ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") + ?: throw SodaException(messageKey = "admin.chat.character.not_found") // 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인 if (request.name != null && request.name != chatCharacter.name) { val existingCharacter = service.findByName(request.name) if (existingCharacter != null) { - throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}") + throw SodaException(messageKey = "admin.chat.character.duplicate_name") } } @@ -438,11 +442,15 @@ class AdminChatCharacterController( // success가 false이면 throw if (!apiResponse.success) { - throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.") + val apiMessage = apiResponse.message + if (apiMessage.isNullOrBlank()) { + throw SodaException(messageKey = "admin.chat.character.update_failed_retry") + } + throw SodaException(apiMessage) } } catch (e: Exception) { e.printStackTrace() - throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "admin.chat.character.update_failed_retry") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt index f67002e3..b4b0c55c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt @@ -63,7 +63,7 @@ class CharacterCurationAdminController( @RequestBody request: CharacterCurationAddCharacterRequest ): ApiResponse { val ids = request.characterIds.filter { it > 0 }.distinct() - if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다") + if (ids.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty") service.addCharacters(curationId, ids) return ApiResponse.ok(true) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt index 77da8f6d..d16f77b0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt @@ -32,7 +32,7 @@ class CharacterCurationAdminService( @Transactional fun update(request: CharacterCurationUpdateRequest): CharacterCuration { val curation = curationRepository.findById(request.id) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } request.title?.let { curation.title = it } request.isAdult?.let { curation.isAdult = it } @@ -44,7 +44,7 @@ class CharacterCurationAdminService( @Transactional fun softDelete(curationId: Long) { val curation = curationRepository.findById(curationId) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } curation.isActive = false curationRepository.save(curation) } @@ -53,7 +53,7 @@ class CharacterCurationAdminService( fun reorder(ids: List) { ids.forEachIndexed { index, id -> val curation = curationRepository.findById(id) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } curation.sortOrder = index + 1 curationRepository.save(curation) } @@ -61,14 +61,14 @@ class CharacterCurationAdminService( @Transactional fun addCharacters(curationId: Long, characterIds: List) { - if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다") + if (characterIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty") val curation = curationRepository.findById(curationId) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } - if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId") + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } + if (!curation.isActive) throw SodaException(messageKey = "admin.chat.curation.inactive") val uniqueIds = characterIds.filter { it > 0 }.distinct() - if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다") + if (uniqueIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.invalid_character_ids") // 활성 캐릭터만 조회 (조회 단계에서 검증 포함) val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds) @@ -101,23 +101,23 @@ class CharacterCurationAdminService( @Transactional fun removeCharacter(curationId: Long, characterId: Long) { val curation = curationRepository.findById(curationId) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } val mappings = mappingRepository.findByCuration(curation) val target = mappings.firstOrNull { it.chatCharacter.id == characterId } - ?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId") + ?: throw SodaException(messageKey = "admin.chat.curation.mapping_not_found") mappingRepository.delete(target) } @Transactional fun reorderCharacters(curationId: Long, characterIds: List) { val curation = curationRepository.findById(curationId) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } val mappings = mappingRepository.findByCuration(curation) val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id } characterIds.forEachIndexed { index, cid -> val mapping = mappingByCharacterId[cid] - ?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid") + ?: throw SodaException(messageKey = "admin.chat.curation.character_not_in_curation") mapping.sortOrder = index + 1 mappingRepository.save(mapping) } @@ -146,7 +146,7 @@ class CharacterCurationAdminService( @Transactional(readOnly = true) fun listCharacters(curationId: Long): List { val curation = curationRepository.findById(curationId) - .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + .orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") } val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation) return mappings.map { it.chatCharacter } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 7a8e1892..219f1da9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.utils.ImageBlurUtil import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value @@ -34,6 +36,8 @@ class AdminCharacterImageController( private val imageService: CharacterImageService, private val s3Uploader: S3Uploader, private val imageCloudFront: ImageContentCloudFront, + private val langContext: LangContext, + private val messageSource: SodaMessageSource, @Value("\${cloud.aws.s3.content-bucket}") private val s3Bucket: String, @@ -106,14 +110,18 @@ class AdminCharacterImageController( @DeleteMapping("/{imageId}") fun delete(@PathVariable imageId: Long) = run { imageService.deleteImage(imageId) - ApiResponse.ok(null, "이미지가 삭제되었습니다.") + val message = messageSource.getMessage("admin.chat.character.image_deleted", langContext.lang) + ApiResponse.ok(null, message) } @PutMapping("/orders") fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run { - if (request.characterId == null) throw SodaException("characterId는 필수입니다") + if (request.characterId == null) { + throw SodaException(messageKey = "admin.chat.character.character_id_required") + } imageService.updateOrders(request.characterId, request.ids) - ApiResponse.ok(null, "정렬 순서가 변경되었습니다.") + val message = messageSource.getMessage("admin.chat.character.order_updated", langContext.lang) + ApiResponse.ok(null, message) } private fun buildS3Key(characterId: Long): String { @@ -132,7 +140,7 @@ class AdminCharacterImageController( metadata = metadata ) } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + throw SodaException(messageKey = "admin.chat.character.image_save_failed") } } @@ -141,7 +149,7 @@ class AdminCharacterImageController( // 멀티파트를 BufferedImage로 읽기 val bytes = image.bytes val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") + ?: throw SodaException(messageKey = "admin.chat.character.image_format_invalid") val blurred = ImageBlurUtil.blurFast(bimg) // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 @@ -164,7 +172,7 @@ class AdminCharacterImageController( metadata = metadata ) } catch (e: Exception) { - throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}") + throw SodaException(messageKey = "admin.chat.character.blur_image_save_failed") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt index 36633694..5fcdc353 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt @@ -58,7 +58,7 @@ class AdminChatCharacterService( @Transactional(readOnly = true) fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse { val chatCharacter = chatCharacterRepository.findById(characterId) - .orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") } + .orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") } return ChatCharacterDetailResponse.from(chatCharacter, imageHost) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt index 95365dd6..6b13b8c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt @@ -192,8 +192,8 @@ class AdminOriginalWorkController( filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}", metadata = metadata ) - } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } catch (_: Exception) { + throw SodaException(messageKey = "admin.chat.original.image_save_failed") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index 22b67b6b..c181f33e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -38,7 +38,7 @@ class AdminOriginalWorkService( @Transactional fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork { originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let { - throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}") + throw SodaException(messageKey = "admin.chat.original.duplicate_title") } val entity = OriginalWork( title = request.title, @@ -107,7 +107,7 @@ class AdminOriginalWorkService( @Transactional fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } request.title?.let { ow.title = it } request.contentType?.let { ow.contentType = it } @@ -177,7 +177,7 @@ class AdminOriginalWorkService( @Transactional fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } ow.imagePath = imagePath return originalWorkRepository.save(ow) } @@ -186,7 +186,7 @@ class AdminOriginalWorkService( @Transactional fun deleteOriginalWork(id: Long) { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } ow.isDeleted = true originalWorkRepository.save(ow) } @@ -195,7 +195,7 @@ class AdminOriginalWorkService( @Transactional(readOnly = true) fun getOriginalWork(id: Long): OriginalWork { return originalWorkRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } } /** 원작 페이징 조회 */ @@ -216,7 +216,7 @@ class AdminOriginalWorkService( fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page { // 원작 존재 및 소프트 삭제 여부 확인 originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } val safePage = if (page < 0) 0 else page val safeSize = when { @@ -238,7 +238,7 @@ class AdminOriginalWorkService( @Transactional fun assignCharacters(originalWorkId: Long, characterIds: List) { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } if (characterIds.isEmpty()) return val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds) characters.forEach { it.originalWork = ow } @@ -250,7 +250,7 @@ class AdminOriginalWorkService( fun unassignCharacters(originalWorkId: Long, characterIds: List) { // 원작 존재 확인 (소프트 삭제 제외) originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } if (characterIds.isEmpty()) return val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds) characters.forEach { it.originalWork = null } @@ -261,13 +261,13 @@ class AdminOriginalWorkService( @Transactional fun assignOneCharacter(originalWorkId: Long, characterId: Long) { val character = chatCharacterRepository.findById(characterId) - .orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") } if (originalWorkId == 0L) { character.originalWork = null } else { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } character.originalWork = ow } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 135fc869..8d2ad780 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -150,6 +150,159 @@ class SodaMessageSource { ) ) + private val adminChatBannerMessages = mapOf( + "admin.chat.banner.image_save_failed" to mapOf( + Lang.KO to "이미지 저장에 실패했습니다.", + Lang.EN to "Failed to save image.", + Lang.JA to "画像の保存に失敗しました。" + ), + "admin.chat.banner.delete_success" to mapOf( + Lang.KO to "배너가 성공적으로 삭제되었습니다.", + Lang.EN to "Banner deleted successfully.", + Lang.JA to "バナーが削除されました。" + ), + "admin.chat.banner.reorder_success" to mapOf( + Lang.KO to "배너 정렬 순서가 성공적으로 변경되었습니다.", + Lang.EN to "Banner order updated successfully.", + Lang.JA to "バナーの並び順が変更されました。" + ) + ) + + private val adminChatCalculateMessages = mapOf( + "admin.chat.calculate.end_date_max_today" to mapOf( + Lang.KO to "끝 날짜는 오늘 날짜까지만 입력 가능합니다.", + Lang.EN to "End date can be at most today.", + Lang.JA to "終了日は本日まで指定できます。" + ), + "admin.chat.calculate.start_date_after_end" to mapOf( + Lang.KO to "시작 날짜는 끝 날짜보다 이후일 수 없습니다.", + Lang.EN to "Start date cannot be after end date.", + Lang.JA to "開始日は終了日より後にできません。" + ), + "admin.chat.calculate.max_period_6_months" to mapOf( + Lang.KO to "조회 가능 기간은 최대 6개월입니다.", + Lang.EN to "Maximum query period is 6 months.", + Lang.JA to "照会期間は最大6ヶ月です。" + ) + ) + + private val adminChatCharacterMessages = mapOf( + "admin.chat.character.duplicate_name" to mapOf( + Lang.KO to "동일한 이름은 등록이 불가능합니다.", + Lang.EN to "A character with the same name already exists.", + Lang.JA to "同じ名前は登録できません。" + ), + "admin.chat.character.register_failed_retry" to mapOf( + Lang.KO to "등록에 실패했습니다. 다시 시도해 주세요.", + Lang.EN to "Registration failed. Please try again.", + Lang.JA to "登録に失敗しました。もう一度お試しください。" + ), + "admin.chat.character.register_failed_no_id" to mapOf( + Lang.KO to "등록에 실패했습니다. 응답에 ID가 없습니다.", + Lang.EN to "Registration failed. No ID in response.", + Lang.JA to "登録に失敗しました。応答にIDがありません。" + ), + "admin.chat.character.image_save_failed" to mapOf( + Lang.KO to "이미지 저장에 실패했습니다.", + Lang.EN to "Failed to save image.", + Lang.JA to "画像の保存に失敗しました。" + ), + "admin.chat.character.no_changes" to mapOf( + Lang.KO to "변경된 데이터가 없습니다.", + Lang.EN to "No changes detected.", + Lang.JA to "変更されたデータがありません。" + ), + "admin.chat.character.not_found" to mapOf( + Lang.KO to "해당 캐릭터를 찾을 수 없습니다.", + Lang.EN to "Character not found.", + Lang.JA to "該当キャラクターが見つかりません。" + ), + "admin.chat.character.update_failed_retry" to mapOf( + Lang.KO to "수정에 실패했습니다. 다시 시도해 주세요.", + Lang.EN to "Update failed. Please try again.", + Lang.JA to "更新に失敗しました。もう一度お試しください。" + ) + ) + + private val adminChatCurationMessages = mapOf( + "admin.chat.curation.not_found" to mapOf( + Lang.KO to "큐레이션을 찾을 수 없습니다.", + Lang.EN to "Curation not found.", + Lang.JA to "キュレーションが見つかりません。" + ), + "admin.chat.curation.character_ids_empty" to mapOf( + Lang.KO to "등록할 캐릭터 ID 리스트가 비어있습니다", + Lang.EN to "Character ID list to register is empty.", + Lang.JA to "登録するキャラクターIDリストが空です。" + ), + "admin.chat.curation.inactive" to mapOf( + Lang.KO to "비활성화된 큐레이션입니다.", + Lang.EN to "Curation is inactive.", + Lang.JA to "無効化されたキュレーションです。" + ), + "admin.chat.curation.invalid_character_ids" to mapOf( + Lang.KO to "유효한 캐릭터 ID가 없습니다", + Lang.EN to "No valid character IDs.", + Lang.JA to "有効なキャラクターIDがありません。" + ), + "admin.chat.curation.mapping_not_found" to mapOf( + Lang.KO to "매핑을 찾을 수 없습니다.", + Lang.EN to "Mapping not found.", + Lang.JA to "マッピングが見つかりません。" + ), + "admin.chat.curation.character_not_in_curation" to mapOf( + Lang.KO to "큐레이션에 포함되지 않은 캐릭터입니다.", + Lang.EN to "Character not included in this curation.", + Lang.JA to "このキュレーションに含まれていないキャラクターです。" + ) + ) + + private val adminChatCharacterImageMessages = mapOf( + "admin.chat.character.image_deleted" to mapOf( + Lang.KO to "이미지가 삭제되었습니다.", + Lang.EN to "Image deleted.", + Lang.JA to "画像が削除されました。" + ), + "admin.chat.character.character_id_required" to mapOf( + Lang.KO to "characterId는 필수입니다", + Lang.EN to "characterId is required.", + Lang.JA to "characterIdは必須です。" + ), + "admin.chat.character.order_updated" to mapOf( + Lang.KO to "정렬 순서가 변경되었습니다.", + Lang.EN to "Order updated.", + Lang.JA to "並び順が変更されました。" + ), + "admin.chat.character.image_format_invalid" to mapOf( + Lang.KO to "이미지 포맷을 인식할 수 없습니다.", + Lang.EN to "Unsupported image format.", + Lang.JA to "画像形式を認識できません。" + ), + "admin.chat.character.blur_image_save_failed" to mapOf( + Lang.KO to "블러 이미지 저장에 실패했습니다.", + Lang.EN to "Failed to save blurred image.", + Lang.JA to "ぼかし画像の保存に失敗しました。" + ) + ) + + private val adminChatOriginalWorkMessages = mapOf( + "admin.chat.original.image_save_failed" to mapOf( + Lang.KO to "이미지 저장에 실패했습니다.", + Lang.EN to "Failed to save image.", + Lang.JA to "画像の保存に失敗しました。" + ), + "admin.chat.original.duplicate_title" to mapOf( + Lang.KO to "동일한 제목의 원작이 이미 존재합니다.", + Lang.EN to "An original work with the same title already exists.", + Lang.JA to "同じタイトルの原作が既に存在します。" + ), + "admin.chat.original.not_found" to mapOf( + Lang.KO to "해당 원작을 찾을 수 없습니다.", + Lang.EN to "Original work not found.", + Lang.JA to "該当の原作が見つかりません。" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, @@ -158,7 +311,13 @@ class SodaMessageSource { auditionNotificationMessages, auditionRoleMessages, settlementRatioMessages, - adminCanMessages + adminCanMessages, + adminChatBannerMessages, + adminChatCalculateMessages, + adminChatCharacterMessages, + adminChatCurationMessages, + adminChatCharacterImageMessages, + adminChatOriginalWorkMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue -- 2.49.1 From 93e0411337ffdfa64cdec42090d81055db9c5a17 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 22:51:19 +0900 Subject: [PATCH 71/90] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/content/AdminContentService.kt | 6 +- .../banner/AdminContentBannerService.kt | 24 ++--- .../curation/AdminContentCurationService.kt | 8 +- .../tag/AdminHashTagCurationService.kt | 6 +- .../series/AdminContentSeriesService.kt | 6 +- .../AdminContentSeriesBannerController.kt | 12 ++- .../genre/AdminContentSeriesGenreService.kt | 4 +- .../recommend/AdminRecommendSeriesService.kt | 4 +- .../content/theme/AdminContentThemeService.kt | 6 +- .../sodalive/i18n/SodaMessageSource.kt | 95 ++++++++++++++++++- 10 files changed, 137 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt index 9733e469..344f9b81 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/AdminContentService.kt @@ -51,7 +51,9 @@ class AdminContentService( searchWord: String, pageable: Pageable ): GetAdminContentListResponse { - if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + if (searchWord.length < 2) { + throw SodaException(messageKey = "admin.content.search_word_min_length") + } val totalCount = repository.getAudioContentTotalCount(searchWord, status = status) val audioContentAndThemeList = repository.getAudioContentList( status = status, @@ -82,7 +84,7 @@ class AdminContentService( @Transactional fun updateAudioContent(request: UpdateAdminContentRequest) { val audioContent = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("없는 콘텐츠 입니다.") + ?: throw SodaException(messageKey = "admin.content.not_found") if (request.isDefaultCoverImage) { audioContent.coverImage = "`profile/default_profile.png`" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt index 3811632e..a6ecbf47 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt @@ -33,19 +33,19 @@ class AdminContentBannerService( fun createAudioContentMainBanner(image: MultipartFile, requestString: String) { val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java) if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) { - throw SodaException("크리에이터를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.creator_required") } if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) { - throw SodaException("시리즈를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.series_required") } if (request.type == AudioContentBannerType.LINK && request.link == null) { - throw SodaException("링크 url을 입력하세요.") + throw SodaException(messageKey = "admin.content.banner.link_required") } if (request.type == AudioContentBannerType.EVENT && request.eventId == null) { - throw SodaException("이벤트를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.event_required") } val event = if (request.eventId != null && request.eventId > 0) { @@ -94,7 +94,7 @@ class AdminContentBannerService( fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) { val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java) val audioContentBanner = repository.findByIdOrNull(request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (image != null) { val fileName = generateFileName() @@ -124,22 +124,22 @@ class AdminContentBannerService( AudioContentBannerType.EVENT -> { if (request.eventId != null) { val event = eventRepository.findByIdOrNull(request.eventId) - ?: throw SodaException("이벤트를 선택하세요.") + ?: throw SodaException(messageKey = "admin.content.banner.event_required") audioContentBanner.event = event } else { - throw SodaException("이벤트를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.event_required") } } AudioContentBannerType.CREATOR -> { if (request.creatorId != null) { val creator = memberRepository.findByIdOrNull(request.creatorId) - ?: throw SodaException("크리에이터를 선택하세요.") + ?: throw SodaException(messageKey = "admin.content.banner.creator_required") audioContentBanner.creator = creator } else { - throw SodaException("크리에이터를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.creator_required") } } @@ -147,18 +147,18 @@ class AdminContentBannerService( if (request.link != null) { audioContentBanner.link = request.link } else { - throw SodaException("링크 url을 입력하세요.") + throw SodaException(messageKey = "admin.content.banner.link_required") } } AudioContentBannerType.SERIES -> { if (request.seriesId != null) { val series = seriesRepository.findByIdOrNull(request.seriesId) - ?: throw SodaException("시리즈를 선택하세요.") + ?: throw SodaException(messageKey = "admin.content.banner.series_required") audioContentBanner.series = series } else { - throw SodaException("시리즈를 선택하세요.") + throw SodaException(messageKey = "admin.content.banner.series_required") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt index a9745f53..d1f5b030 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/AdminContentCurationService.kt @@ -21,7 +21,7 @@ class AdminContentCurationService( @Transactional fun createContentCuration(request: CreateContentCurationRequest) { val tab = contentMainTabRepository.findByIdOrNull(request.tabId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val curation = AudioContentCuration( title = request.title, @@ -37,7 +37,7 @@ class AdminContentCurationService( @Transactional fun updateContentCuration(request: UpdateContentCurationRequest) { val audioContentCuration = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (request.title != null) { audioContentCuration.title = request.title @@ -85,7 +85,7 @@ class AdminContentCurationService( fun getCurationItem(curationId: Long): List { val curation = repository.findByIdOrNull(curationId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") return if (curation.isSeries) { contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId) @@ -106,7 +106,7 @@ class AdminContentCurationService( fun addItemToCuration(request: AddItemToCurationRequest) { // 큐레이션 조회 val audioContentCuration = repository.findByIdOrNull(id = request.curationId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (audioContentCuration.isSeries) { request.itemIdList.forEach { seriesId -> diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/tag/AdminHashTagCurationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/tag/AdminHashTagCurationService.kt index dc450067..9f1be258 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/tag/AdminHashTagCurationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/curation/tag/AdminHashTagCurationService.kt @@ -28,7 +28,7 @@ class AdminHashTagCurationService( val isExists = repository.isExistsTag(tag = tag) if (isExists) { - throw SodaException("이미 등록된 태그 입니다.") + throw SodaException(messageKey = "admin.content.hash_tag.already_registered") } repository.save( @@ -42,7 +42,7 @@ class AdminHashTagCurationService( @Transactional fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) { val hashTagCuration = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (request.tag != null) { var tag = request.tag.trim() @@ -88,7 +88,7 @@ class AdminHashTagCurationService( @Transactional fun addItemToHashTagCuration(request: AddItemToCurationRequest) { val curation = repository.findByIdOrNull(id = request.curationId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") request.itemIdList.forEach { contentId -> val audioContent = audioContentRepository.findByIdAndActive(contentId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt index 31505cfd..18e61d86 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/AdminContentSeriesService.kt @@ -43,12 +43,12 @@ class AdminContentSeriesService( @Transactional fun modifySeries(request: AdminModifySeriesRequest) { val series = repository.findByIdAndActiveTrue(request.seriesId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (request.publishedDaysOfWeek != null) { val days = request.publishedDaysOfWeek if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) { - throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.") + throw SodaException(messageKey = "admin.content.series.random_days_conflict") } series.publishedDaysOfWeek.clear() series.publishedDaysOfWeek.addAll(days) @@ -56,7 +56,7 @@ class AdminContentSeriesService( if (request.genreId != null) { val genre = genreRepository.findActiveSeriesGenreById(request.genreId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") series.genre = genre } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt index 5763fe61..6f8c3d4b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/banner/AdminContentSeriesBannerController.kt @@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest @@ -33,6 +35,8 @@ import org.springframework.web.multipart.MultipartFile class AdminContentSeriesBannerController( private val bannerService: ContentSeriesBannerService, private val s3Uploader: S3Uploader, + private val langContext: LangContext, + private val messageSource: SodaMessageSource, @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String, @@ -113,7 +117,8 @@ class AdminContentSeriesBannerController( @DeleteMapping("/{bannerId}") fun deleteBanner(@PathVariable bannerId: Long) = run { bannerService.deleteBanner(bannerId) - ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") + val message = messageSource.getMessage("admin.content.series.banner.delete_success", langContext.lang) + ApiResponse.ok(message) } /** @@ -124,7 +129,8 @@ class AdminContentSeriesBannerController( @RequestBody request: UpdateBannerOrdersRequest ) = run { bannerService.updateBannerOrders(request.ids) - ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.") + val message = messageSource.getMessage("admin.content.series.banner.reorder_success", langContext.lang) + ApiResponse.ok(null, message) } private fun saveImage(bannerId: Long, image: MultipartFile): String { @@ -139,7 +145,7 @@ class AdminContentSeriesBannerController( metadata = metadata ) } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + throw SodaException(messageKey = "admin.content.series.banner.image_save_failed") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreService.kt index 68eb2848..05735dea 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/genre/AdminContentSeriesGenreService.kt @@ -17,11 +17,11 @@ class AdminContentSeriesGenreService(private val repository: AdminContentSeriesG @Transactional fun modifySeriesGenre(request: ModifySeriesGenreRequest) { if (request.genre == null && request.isAdult == null && request.isActive == null) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "admin.content.series.genre.no_changes") } val seriesGenre = repository.findByIdOrNull(id = request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (request.genre != null) { seriesGenre.genre = request.genre diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/recommend/AdminRecommendSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/recommend/AdminRecommendSeriesService.kt index cf697e4b..fbc37ac1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/recommend/AdminRecommendSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/series/recommend/AdminRecommendSeriesService.kt @@ -30,7 +30,7 @@ class AdminRecommendSeriesService( fun createRecommendSeries(image: MultipartFile, requestString: String) { val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java) val series = seriesRepository.findByIdOrNull(request.seriesId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val recommendSeries = RecommendSeries(isFree = request.isFree) recommendSeries.series = series @@ -49,7 +49,7 @@ class AdminRecommendSeriesService( fun updateRecommendSeries(image: MultipartFile?, requestString: String) { val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java) val recommendSeries = repository.findByIdOrNull(request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (image != null) { val fileName = generateFileName() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt index 896f636b..2c4b0aef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/content/theme/AdminContentThemeService.kt @@ -53,13 +53,15 @@ class AdminContentThemeService( } fun themeExistCheck(request: CreateContentThemeRequest) { - repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") } + repository.findIdByTheme(request.theme)?.let { + throw SodaException(messageKey = "admin.content.theme.already_registered") + } } @Transactional fun deleteTheme(id: Long) { val theme = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") theme.theme = "${theme.theme}_deleted" theme.isActive = false diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 8d2ad780..dc9456d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -303,6 +303,92 @@ class SodaMessageSource { ) ) + private val adminContentMessages = mapOf( + "admin.content.search_word_min_length" to mapOf( + Lang.KO to "2글자 이상 입력하세요.", + Lang.EN to "Please enter at least 2 characters.", + Lang.JA to "2文字以上入力してください。" + ), + "admin.content.not_found" to mapOf( + Lang.KO to "없는 콘텐츠 입니다.", + Lang.EN to "Content not found.", + Lang.JA to "該当のコンテンツが見つかりません。" + ) + ) + + private val adminContentBannerMessages = mapOf( + "admin.content.banner.creator_required" to mapOf( + Lang.KO to "크리에이터를 선택하세요.", + Lang.EN to "Please select a creator.", + Lang.JA to "クリエイターを選択してください。" + ), + "admin.content.banner.series_required" to mapOf( + Lang.KO to "시리즈를 선택하세요.", + Lang.EN to "Please select a series.", + Lang.JA to "シリーズを選択してください。" + ), + "admin.content.banner.link_required" to mapOf( + Lang.KO to "링크 url을 입력하세요.", + Lang.EN to "Please enter a link URL.", + Lang.JA to "リンクURLを入力してください。" + ), + "admin.content.banner.event_required" to mapOf( + Lang.KO to "이벤트를 선택하세요.", + Lang.EN to "Please select an event.", + Lang.JA to "イベントを選択してください。" + ) + ) + + private val adminHashTagCurationMessages = mapOf( + "admin.content.hash_tag.already_registered" to mapOf( + Lang.KO to "이미 등록된 태그 입니다.", + Lang.EN to "Tag already registered.", + Lang.JA to "既に登録されたタグです。" + ) + ) + + private val adminContentSeriesMessages = mapOf( + "admin.content.series.random_days_conflict" to mapOf( + Lang.KO to "랜덤과 연재요일 동시에 선택할 수 없습니다.", + Lang.EN to "Random and published days cannot be selected together.", + Lang.JA to "ランダムと連載曜日を同時に選択できません。" + ) + ) + + private val adminContentSeriesBannerMessages = mapOf( + "admin.content.series.banner.delete_success" to mapOf( + Lang.KO to "배너가 성공적으로 삭제되었습니다.", + Lang.EN to "Banner deleted successfully.", + Lang.JA to "バナーが削除されました。" + ), + "admin.content.series.banner.reorder_success" to mapOf( + Lang.KO to "배너 정렬 순서가 성공적으로 변경되었습니다.", + Lang.EN to "Banner order updated successfully.", + Lang.JA to "バナーの並び順が変更されました。" + ), + "admin.content.series.banner.image_save_failed" to mapOf( + Lang.KO to "이미지 저장에 실패했습니다.", + Lang.EN to "Failed to save image.", + Lang.JA to "画像の保存に失敗しました。" + ) + ) + + private val adminContentSeriesGenreMessages = mapOf( + "admin.content.series.genre.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes to update.", + Lang.JA to "変更事項がありません。" + ) + ) + + private val adminContentThemeMessages = mapOf( + "admin.content.theme.already_registered" to mapOf( + Lang.KO to "이미 등록된 테마 입니다.", + Lang.EN to "Theme already registered.", + Lang.JA to "既に登録されたテーマです。" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, @@ -317,7 +403,14 @@ class SodaMessageSource { adminChatCharacterMessages, adminChatCurationMessages, adminChatCharacterImageMessages, - adminChatOriginalWorkMessages + adminChatOriginalWorkMessages, + adminContentMessages, + adminContentBannerMessages, + adminHashTagCurationMessages, + adminContentSeriesMessages, + adminContentSeriesBannerMessages, + adminContentSeriesGenreMessages, + adminContentThemeMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue -- 2.49.1 From 4dcf9f6ed1d2a6f02a3a99249ad3c370c9625c93 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Dec 2025 23:12:29 +0900 Subject: [PATCH 72/90] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 API 변경 없음. --- .../sodalive/i18n/SodaMessageSource.kt | 88 ++++++++++++++++++- .../co/vividnext/sodalive/menu/MenuService.kt | 2 +- .../sodalive/message/MessageController.kt | 22 ++--- .../sodalive/message/MessageService.kt | 50 +++++++---- .../sodalive/notice/ServiceNoticeService.kt | 14 +-- .../sodalive/point/PointController.kt | 6 +- .../sodalive/report/ReportController.kt | 13 ++- .../sodalive/report/ReportService.kt | 8 +- .../sodalive/search/SearchController.kt | 8 +- .../useraction/UserActionController.kt | 4 +- .../sodalive/utils/ImageValidation.kt | 4 +- 11 files changed, 165 insertions(+), 54 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index dc9456d0..cf1f9264 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -389,6 +389,88 @@ class SodaMessageSource { ) ) + private val messageMessages = mapOf( + "message.error.recipient_not_found" to mapOf( + Lang.KO to "받는 사람이 없습니다.", + Lang.EN to "Recipient not found.", + Lang.JA to "受信者が見つかりません。" + ), + "message.error.recipient_inactive" to mapOf( + Lang.KO to "탈퇴한 유저에게는 메시지를 보내실 수 없습니다.", + Lang.EN to "You cannot send messages to a deactivated user.", + Lang.JA to "退会したユーザーにはメッセージを送れません。" + ), + "message.error.blocked_by_recipient" to mapOf( + Lang.KO to "%s님의 요청으로 메시지를 보낼 수 없습니다.", + Lang.EN to "You cannot send messages at %s's request.", + Lang.JA to "%sの要請によりメッセージを送信できません。" + ), + "message.fcm.title" to mapOf( + Lang.KO to "메시지", + Lang.EN to "Message", + Lang.JA to "メッセージ" + ), + "message.fcm.text_received" to mapOf( + Lang.KO to "%s님으로 부터 문자메시지가 도착했습니다.", + Lang.EN to "You have received a text message from %s.", + Lang.JA to "%sからテキストメッセージが届きました。" + ), + "message.fcm.voice_received" to mapOf( + Lang.KO to "%s님으로 부터 음성메시지가 도착했습니다.", + Lang.EN to "You have received a voice message from %s.", + Lang.JA to "%sからボイスメッセージが届きました。" + ), + "message.error.not_found_retry" to mapOf( + Lang.KO to "해당하는 메시지가 없습니다.\n다시 확인해 주시기 바랍니다.", + Lang.EN to "Message not found. Please check again.", + Lang.JA to "該当するメッセージがありません。\nもう一度ご確認ください。" + ), + "message.error.already_kept" to mapOf( + Lang.KO to "이미 보관된 메시지 입니다.", + Lang.EN to "Message is already kept.", + Lang.JA to "すでに保管されたメッセージです。" + ) + ) + + private val noticeMessages = mapOf( + "notice.error.title_required" to mapOf( + Lang.KO to "제목을 입력하세요.", + Lang.EN to "Please enter a title.", + Lang.JA to "タイトルを入力してください。" + ), + "notice.error.content_required" to mapOf( + Lang.KO to "내용을 입력하세요.", + Lang.EN to "Please enter content.", + Lang.JA to "内容を入力してください。" + ), + "notice.error.update_required" to mapOf( + Lang.KO to "수정할 내용을 입력하세요.", + Lang.EN to "Please enter content to update.", + Lang.JA to "修正する内容を入力してください。" + ) + ) + + private val reportMessages = mapOf( + "report.received" to mapOf( + Lang.KO to "신고가 접수되었습니다.", + Lang.EN to "Your report has been received.", + Lang.JA to "通報が受け付けられました。" + ) + ) + + private val imageValidationMessages = mapOf( + "image.error.only_image_allowed" to mapOf( + Lang.KO to "이미지 파일만 업로드할 수 있습니다.", + Lang.EN to "Only image files can be uploaded.", + Lang.JA to "画像ファイルのみアップロードできます。" + ), + "image.error.gif_paid_only" to mapOf( + Lang.KO to "GIF 파일은 유료 게시물만 업로드 할 수 있습니다.", + Lang.EN to "GIF files can be uploaded only for paid posts.", + Lang.JA to "GIFファイルは有料投稿のみアップロードできます。" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, @@ -410,7 +492,11 @@ class SodaMessageSource { adminContentSeriesMessages, adminContentSeriesBannerMessages, adminContentSeriesGenreMessages, - adminContentThemeMessages + adminContentThemeMessages, + messageMessages, + noticeMessages, + reportMessages, + imageValidationMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt index bb7dc6e4..3ebd9d39 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/menu/MenuService.kt @@ -12,7 +12,7 @@ class MenuService( ) { fun getMenus(user: User): List { val member = memberRepository.findByEmail(user.username) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") return repository.getMenu(member.role) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt index 1cb13b3a..a4aff406 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageController.kt @@ -25,7 +25,7 @@ class MessageController(private val service: MessageService) { @RequestBody request: SendTextMessageRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.sendTextMessage(request, member)) } @@ -36,7 +36,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getSentTextMessages(member, pageable, timezone)) } @@ -47,7 +47,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getReceivedTextMessages(member, pageable, timezone)) } @@ -58,7 +58,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getKeepTextMessages(member, pageable, timezone)) } @@ -68,7 +68,7 @@ class MessageController(private val service: MessageService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.keepTextMessage(id, member)) } @@ -78,7 +78,7 @@ class MessageController(private val service: MessageService) { @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.sendVoiceMessage(voiceMessageFile, requestString, member)) } @@ -89,7 +89,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getSentVoiceMessages(member, pageable, timezone)) } @@ -99,7 +99,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getReceivedVoiceMessages(member, pageable, timezone)) } @@ -110,7 +110,7 @@ class MessageController(private val service: MessageService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getKeepVoiceMessages(member, pageable, timezone)) } @@ -120,7 +120,7 @@ class MessageController(private val service: MessageService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.keepVoiceMessage(id, member)) } @@ -129,7 +129,7 @@ class MessageController(private val service: MessageService) { @PathVariable messageId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.deleteMessage(messageId, member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt index 78c87772..c968ad61 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/message/MessageService.kt @@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository @@ -26,6 +28,8 @@ class MessageService( private val repository: MessageRepository, private val memberRepository: MemberRepository, private val blockMemberRepository: BlockMemberRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, private val applicationEventPublisher: ApplicationEventPublisher, private val objectMapper: ObjectMapper, @@ -39,17 +43,21 @@ class MessageService( @Transactional fun sendTextMessage(request: SendTextMessageRequest, member: Member) { val recipient = memberRepository.findByIdOrNull(request.recipientId) - ?: throw SodaException("받는 사람이 없습니다.") + ?: throw SodaException(messageKey = "message.error.recipient_not_found") if (!recipient.isActive) { - throw SodaException("탈퇴한 유저에게는 메시지를 보내실 수 없습니다.") + throw SodaException(messageKey = "message.error.recipient_inactive") } val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = request.recipientId) - if (isBlocked) throw SodaException("${recipient.nickname}님의 요청으로 메시지를 보낼 수 없습니다.") + if (isBlocked) { + val messageTemplate = messageSource.getMessage("message.error.blocked_by_recipient", langContext.lang).orEmpty() + val message = String.format(messageTemplate, recipient.nickname) + throw SodaException(message = message) + } val sender = memberRepository.findByIdOrNull(member.id!!) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") val message = Message( textMessage = request.textMessage, @@ -64,8 +72,11 @@ class MessageService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.SEND_MESSAGE, - title = "메시지", - message = "${sender.nickname}님으로 부터 문자메시지가 도착했습니다.", + title = messageSource.getMessage("message.fcm.title", langContext.lang).orEmpty(), + message = run { + val messageTemplate = messageSource.getMessage("message.fcm.text_received", langContext.lang).orEmpty() + String.format(messageTemplate, sender.nickname) + }, messageId = message.id ) ) @@ -99,17 +110,21 @@ class MessageService( val request = objectMapper.readValue(requestString, SendVoiceMessageRequest::class.java) val recipient = memberRepository.findByIdOrNull(request.recipientId) - ?: throw SodaException("받는 사람이 없습니다.") + ?: throw SodaException(messageKey = "message.error.recipient_not_found") if (!recipient.isActive) { - throw SodaException("탈퇴한 유저에게는 메시지를 보내실 수 없습니다.") + throw SodaException(messageKey = "message.error.recipient_inactive") } val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = request.recipientId) - if (isBlocked) throw SodaException("${recipient.nickname}님의 요청으로 메시지를 보낼 수 없습니다.") + if (isBlocked) { + val messageTemplate = messageSource.getMessage("message.error.blocked_by_recipient", langContext.lang).orEmpty() + val message = String.format(messageTemplate, recipient.nickname) + throw SodaException(message = message) + } val sender = memberRepository.findByIdOrNull(member.id!!) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") val message = Message(messageType = MessageType.VOICE) message.sender = sender @@ -132,8 +147,11 @@ class MessageService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.SEND_MESSAGE, - title = "메시지", - message = "${sender.nickname}님으로 부터 음성메시지가 도착했습니다.", + title = messageSource.getMessage("message.fcm.title", langContext.lang).orEmpty(), + message = run { + val messageTemplate = messageSource.getMessage("message.fcm.voice_received", langContext.lang).orEmpty() + String.format(messageTemplate, sender.nickname) + }, messageId = message.id ) ) @@ -166,7 +184,7 @@ class MessageService( @Transactional fun deleteMessage(messageId: Long, member: Member) { val message = repository.findByIdOrNull(messageId) - ?: throw SodaException("해당하는 메시지가 없습니다.\n다시 확인해 주시기 바랍니다.") + ?: throw SodaException(messageKey = "message.error.not_found_retry") if (message.sender!!.id!! == member.id!!) { message.isSenderDelete = true @@ -247,14 +265,14 @@ class MessageService( private fun keepMessage(messageId: Long, member: Member) { val message = repository.findByIdOrNull(messageId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (message.recipient != member) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } if (message.isRecipientKeep) { - throw SodaException("이미 보관된 메시지 입니다.") + throw SodaException(messageKey = "message.error.already_kept") } message.isRecipientKeep = true diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt index 8aa890e9..a5d873b7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeService.kt @@ -11,8 +11,8 @@ import org.springframework.transaction.annotation.Transactional class ServiceNoticeService(private val repository: ServiceServiceNoticeRepository) { @Transactional fun save(request: CreateNoticeRequest): Long { - if (request.title.isBlank()) throw SodaException("제목을 입력하세요.") - if (request.content.isBlank()) throw SodaException("내용을 입력하세요.") + if (request.title.isBlank()) throw SodaException(messageKey = "notice.error.title_required") + if (request.content.isBlank()) throw SodaException(messageKey = "notice.error.content_required") val notice = request.toEntity() return repository.save(notice).id!! @@ -20,13 +20,13 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor @Transactional fun update(request: UpdateNoticeRequest) { - if (request.id <= 0) throw SodaException("잘못된 요청입니다.") + if (request.id <= 0) throw SodaException(messageKey = "common.error.invalid_request") if (request.title.isNullOrBlank() && request.content.isNullOrBlank()) { - throw SodaException("수정할 내용을 입력하세요.") + throw SodaException(messageKey = "notice.error.update_required") } val notice = repository.findByIdOrNull(request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (!request.title.isNullOrBlank()) notice.title = request.title if (!request.content.isNullOrBlank()) notice.content = request.content @@ -34,9 +34,9 @@ class ServiceNoticeService(private val repository: ServiceServiceNoticeRepositor @Transactional fun delete(id: Long) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val notice = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") notice.isActive = false } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt index 04d20c09..1abdca04 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/point/PointController.kt @@ -17,7 +17,7 @@ class PointController(private val service: PointService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getPointStatus(member)) @@ -29,7 +29,7 @@ class PointController(private val service: PointService) { @RequestParam("timezone") timezone: String ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getPointUseStatus(member, timezone)) @@ -41,7 +41,7 @@ class PointController(private val service: PointService) { @RequestParam("timezone") timezone: String ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getPointRewardStatus(member, timezone)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt index 10a29039..a936ee8f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.report import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PostMapping @@ -11,13 +13,18 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/report") -class ReportController(private val service: ReportService) { +class ReportController( + private val service: ReportService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @PostMapping fun report( @RequestBody request: ReportRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok(service.save(member, request), "신고가 접수되었습니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val message = messageSource.getMessage("report.received", langContext.lang) + ApiResponse.ok(service.save(member, request), message) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt index 5a8107b9..d7632034 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/report/ReportService.kt @@ -20,26 +20,26 @@ class ReportService( @Transactional fun save(member: Member, request: ReportRequest) { if (conditionAllIsNull(request, isNull = true) || conditionAllIsNull(request, isNull = false)) { - throw SodaException("신고가 접수되었습니다.") + throw SodaException(messageKey = "report.received") } val reportedAccount = if (request.reportedMemberId != null) { memberRepository.findByIdOrNull(request.reportedMemberId) - ?: throw SodaException("신고가 접수되었습니다.") + ?: throw SodaException(messageKey = "report.received") } else { null } val cheers = if (request.cheersId != null) { cheersRepository.findByIdOrNull(request.cheersId) - ?: throw SodaException("신고가 접수되었습니다.") + ?: throw SodaException(messageKey = "report.received") } else { null } val communityPost = if (request.communityPostId != null) { creatorCommunityRepository.findByIdOrNull(request.communityPostId) - ?: throw SodaException("신고가 접수되었습니다.") + ?: throw SodaException(messageKey = "report.received") } else { null } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt index 4fb0ab4a..5ad5d9b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/search/SearchController.kt @@ -21,7 +21,7 @@ class SearchController(private val service: SearchService) { @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchUnified( keyword, @@ -40,7 +40,7 @@ class SearchController(private val service: SearchService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchCreatorList( keyword, @@ -61,7 +61,7 @@ class SearchController(private val service: SearchService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchContentList( keyword, @@ -82,7 +82,7 @@ class SearchController(private val service: SearchService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchSeriesList( keyword, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt index c01e3949..100d5252 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionController.kt @@ -17,7 +17,7 @@ class UserActionController(private val service: UserActionService) { @RequestBody request: UserActionRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") service.recordAction( memberId = member.id!!, @@ -25,6 +25,6 @@ class UserActionController(private val service: UserActionService) { actionType = request.actionType ) - ApiResponse.ok(Unit, "") + ApiResponse.ok(Unit) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt index 24430640..0ce2997d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt @@ -12,10 +12,10 @@ fun validateImage(file: MultipartFile, gifAllowed: Boolean) { val mimeType = Tika().detect(file.bytes) if (!mimeType.startsWith("image/")) { - throw SodaException("이미지 파일만 업로드할 수 있습니다.") + throw SodaException(messageKey = "image.error.only_image_allowed") } if (mimeType == "image/gif" && !gifAllowed) { - throw SodaException("GIF 파일은 유료 게시물만 업로드 할 수 있습니다.") + throw SodaException(messageKey = "image.error.gif_paid_only") } } -- 2.49.1 From 67b909daedfa74398282147bddadf44c82b5b40f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 13:26:15 +0900 Subject: [PATCH 73/90] =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회원/인증 API 응답 메시지를 다국어 키로 분리함. --- .../sodalive/i18n/SodaMessageSource.kt | 199 +++++++++++++++++- .../sodalive/member/MemberController.kt | 43 ++-- .../sodalive/member/MemberService.kt | 161 +++++++------- .../sodalive/member/auth/AuthController.kt | 4 +- .../sodalive/member/auth/AuthService.kt | 28 ++- .../nickname/NicknameGenerateService.kt | 2 +- .../member/social/google/GoogleAuthService.kt | 2 +- .../member/social/google/GoogleService.kt | 2 +- .../member/social/kakao/KakaoAuthService.kt | 2 +- .../member/social/kakao/KakaoService.kt | 2 +- .../member/stipulation/StipulationService.kt | 6 +- .../member/tag/MemberTagController.kt | 2 +- 12 files changed, 338 insertions(+), 115 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index cf1f9264..49e52017 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -471,6 +471,197 @@ class SodaMessageSource { ) ) + private val memberAuthMessages = mapOf( + "member.auth.blocked_policy" to mapOf( + Lang.KO to "운영정책을 위반하여 이용을 제한합니다.", + Lang.EN to "Your access is restricted due to policy violations.", + Lang.JA to "運営ポリシー違反のため利用が制限されています。" + ), + "member.auth.already_verified" to mapOf( + Lang.KO to "이미 인증된 계정입니다.", + Lang.EN to "This account is already verified.", + Lang.JA to "既に認証済みのアカウントです。" + ), + "member.auth.certificate_invalid_retry" to mapOf( + Lang.KO to "인증정보에 오류가 있습니다.\n다시 시도해 주세요.", + Lang.EN to "There is an error with the verification information.\nPlease try again.", + Lang.JA to "認証情報にエラーがあります。\nもう一度お試しください。" + ), + "member.auth.max_accounts" to mapOf( + Lang.KO to "이미 본인인증한 계정 %s개 이용중입니다.\n" + + "소다라이브의 본인인증은 최대 3개의 계정만 이용할 수 있습니다.", + Lang.EN to "You are already using %s verified account(s).\n" + + "Identity verification is limited to up to 3 accounts on Sodalive.", + Lang.JA to "本人認証済みのアカウントを%s件利用中です。\n" + + "ソダライブの本人認証は最大3アカウントまでです。" + ), + "member.auth.age_limit" to mapOf( + Lang.KO to "%s년 1월 1일 이전 출생자만 본인인증이 가능합니다.", + Lang.EN to "Only users born on or before January 1, %s can be verified.", + Lang.JA to "%s年1月1日以前に生まれた方のみ本人認証が可能です。" + ) + ) + + private val memberMessages = mapOf( + "member.signup.failed_retry" to mapOf( + Lang.KO to "회원가입을 하지 못했습니다.\n다시 시도해 주세요.", + Lang.EN to "Sign up failed.\nPlease try again.", + Lang.JA to "会員登録に失敗しました。\nもう一度お試しください。" + ), + "member.signup.success" to mapOf( + Lang.KO to "회원가입을 축하드립니다.", + Lang.EN to "Congratulations on your sign up.", + Lang.JA to "ご登録おめでとうございます。" + ), + "member.login.success" to mapOf( + Lang.KO to "로그인 되었습니다.", + Lang.EN to "You are logged in.", + Lang.JA to "ログインしました。" + ), + "member.signout.success" to mapOf( + Lang.KO to "정상적으로 탈퇴 처리되었습니다.", + Lang.EN to "Your account has been successfully deleted.", + Lang.JA to "正常に退会処理されました。" + ) + ) + + private val memberValidationMessages = mapOf( + "member.validation.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease close the app and try again.", + Lang.JA to "不正なリクエストです。\nアプリを終了して再度お試しください。" + ), + "member.validation.agree_required" to mapOf( + Lang.KO to "약관에 동의하셔야 회원가입이 가능합니다.", + Lang.EN to "You must agree to the terms to sign up.", + Lang.JA to "会員登録には規約への同意が必要です。" + ), + "member.validation.user_not_found" to mapOf( + Lang.KO to "없는 사용자 입니다.", + Lang.EN to "User not found.", + Lang.JA to "ユーザーが見つかりません。" + ), + "member.validation.account_not_found" to mapOf( + Lang.KO to "없는 계정입니다.", + Lang.EN to "Account not found.", + Lang.JA to "アカウントが見つかりません。" + ), + "member.validation.inactive_account" to mapOf( + Lang.KO to "탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.", + Lang.EN to "This account has been deleted.\nPlease contact customer support.", + Lang.JA to "退会したアカウントです。\nカスタマーサポートへお問い合わせください。" + ), + "member.validation.creator_not_found" to mapOf( + Lang.KO to "크리에이터 정보를 확인해주세요.", + Lang.EN to "Please check the creator information.", + Lang.JA to "クリエイター情報を確認してください。" + ), + "member.validation.nickname_min_length" to mapOf( + Lang.KO to "두 글자 이상 입력 하셔야 합니다.", + Lang.EN to "Please enter at least 2 characters.", + Lang.JA to "2文字以上入力してください。" + ), + "member.validation.password_mismatch" to mapOf( + Lang.KO to "비밀번호가 일치하지 않습니다.", + Lang.EN to "Password does not match.", + Lang.JA to "パスワードが一致しません。" + ), + "member.validation.signout_reason_required" to mapOf( + Lang.KO to "탈퇴하려는 이유를 입력해 주세요.", + Lang.EN to "Please enter a reason for deleting your account.", + Lang.JA to "退会理由を入力してください。" + ), + "member.validation.email_available" to mapOf( + Lang.KO to "사용 가능한 이메일 입니다.", + Lang.EN to "This email is available.", + Lang.JA to "使用可能なメールアドレスです。" + ), + "member.validation.nickname_available" to mapOf( + Lang.KO to "사용 가능한 닉네임 입니다.", + Lang.EN to "This nickname is available.", + Lang.JA to "使用可能なニックネームです。" + ), + "member.validation.email_in_use" to mapOf( + Lang.KO to "이미 사용중인 이메일 입니다.", + Lang.EN to "This email is already in use.", + Lang.JA to "このメールアドレスは既に使用されています。" + ), + "member.validation.nickname_in_use" to mapOf( + Lang.KO to "이미 사용중인 닉네임 입니다.", + Lang.EN to "This nickname is already in use.", + Lang.JA to "このニックネームは既に使用されています。" + ), + "member.validation.email_registered_with_provider" to mapOf( + Lang.KO to "해당 이메일은 %s 계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.", + Lang.EN to "This email is registered with a %s account. Please use that social login.", + Lang.JA to "このメールアドレスは%sアカウントで登録されています。該当のソーシャルログインをご利用ください。" + ), + "member.validation.email_registered_with_provider_already" to mapOf( + Lang.KO to "해당 이메일은 %s 계정으로 이미 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.", + Lang.EN to "This email is already registered with a %s account. Please use that social login.", + Lang.JA to "このメールアドレスは既に%sアカウントで登録されています。該当のソーシャルログインをご利用ください。" + ), + "member.validation.unregistered_account_retry" to mapOf( + Lang.KO to "등록되지 않은 계정입니다.\n확인 후 다시 시도해 주세요.", + Lang.EN to "This account is not registered.\nPlease check and try again.", + Lang.JA to "登録されていないアカウントです。\n確認してもう一度お試しください。" + ) + ) + + private val memberSocialMessages = mapOf( + "member.social.google_login_failed" to mapOf( + Lang.KO to "구글 로그인을 하지 못했습니다. 다시 시도해 주세요", + Lang.EN to "Google login failed. Please try again.", + Lang.JA to "Googleログインに失敗しました。もう一度お試しください。" + ), + "member.social.kakao_login_failed" to mapOf( + Lang.KO to "카카오 로그인을 하지 못했습니다. 다시 시도해 주세요", + Lang.EN to "Kakao login failed. Please try again.", + Lang.JA to "カカオログインに失敗しました。もう一度お試しください。" + ), + "member.social.email_consent_required" to mapOf( + Lang.KO to "이메일 제공에 동의하셔야 서비스 이용이 가능합니다.", + Lang.EN to "You must agree to provide your email to use the service.", + Lang.JA to "サービス利用にはメール提供への同意が必要です。" + ) + ) + + private val memberProviderMessages = mapOf( + "member.provider.email" to mapOf( + Lang.KO to "이메일", + Lang.EN to "Email", + Lang.JA to "メール" + ), + "member.provider.kakao" to mapOf( + Lang.KO to "카카오", + Lang.EN to "Kakao", + Lang.JA to "カカオ" + ), + "member.provider.google" to mapOf( + Lang.KO to "구글", + Lang.EN to "Google", + Lang.JA to "Google" + ), + "member.provider.apple" to mapOf( + Lang.KO to "애플", + Lang.EN to "Apple", + Lang.JA to "Apple" + ) + ) + + private val memberGenderMessages = mapOf( + "member.gender.male" to mapOf( + Lang.KO to "남", + Lang.EN to "Male", + Lang.JA to "男性" + ), + "member.gender.female" to mapOf( + Lang.KO to "여", + Lang.EN to "Female", + Lang.JA to "女性" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, @@ -496,7 +687,13 @@ class SodaMessageSource { messageMessages, noticeMessages, reportMessages, - imageValidationMessages + imageValidationMessages, + memberAuthMessages, + memberMessages, + memberValidationMessages, + memberSocialMessages, + memberProviderMessages, + memberGenderMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index e6a094a5..3bc333ae 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.member import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType import kr.co.vividnext.sodalive.marketing.AdTrackingService import kr.co.vividnext.sodalive.member.block.MemberBlockRequest @@ -37,7 +39,9 @@ class MemberController( private val kakaoAuthService: KakaoAuthService, private val googleAuthService: GoogleAuthService, private val trackingService: AdTrackingService, - private val userActionService: UserActionService + private val userActionService: UserActionService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext ) { @GetMapping("/check/email") fun checkEmail(@RequestParam email: String) = service.duplicateCheckEmail(email) @@ -69,7 +73,8 @@ class MemberController( actionType = ActionType.SIGN_UP ) - return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + val message = messageSource.getMessage("member.signup.success", langContext.lang) + return ApiResponse.ok(message = message, data = response.loginResponse) } @PostMapping("/signup") @@ -87,7 +92,8 @@ class MemberController( ) } - return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + val message = messageSource.getMessage("member.signup.success", langContext.lang) + return ApiResponse.ok(message = message, data = response.loginResponse) } @PostMapping("/login") @@ -230,7 +236,7 @@ class MemberController( @RequestBody request: CreatorFollowRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.creatorUnFollow(creatorId = request.creatorId, memberId = member.id!!)) } @@ -240,7 +246,7 @@ class MemberController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getBlockedMemberIdList(member.id!!)) } @@ -250,7 +256,7 @@ class MemberController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getBlockedMemberList( @@ -266,7 +272,7 @@ class MemberController( @RequestBody request: MemberBlockRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.memberBlock(request = request, memberId = member.id!!)) } @@ -276,7 +282,7 @@ class MemberController( @RequestBody request: MemberBlockRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.memberUnBlock(request = request, memberId = member.id!!)) } @@ -286,7 +292,7 @@ class MemberController( @RequestParam nickname: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.searchMember(nickname = nickname, member = member)) } @@ -295,13 +301,16 @@ class MemberController( fun signOut( @RequestBody signOutRequest: SignOutRequest, @AuthenticationPrincipal user: User - ) = ApiResponse.ok(service.signOut(signOutRequest, user), "정상적으로 탈퇴 처리되었습니다.") + ) = ApiResponse.ok( + service.signOut(signOutRequest, user), + messageSource.getMessage("member.signout.success", langContext.lang) + ) @GetMapping("/change/nickname/price") fun getChangeNicknamePrice( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getChangeNicknamePrice(memberId = member.id!!)) } @@ -327,7 +336,7 @@ class MemberController( @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getMemberProfile(memberId = id, myMemberId = member.id!!)) } @@ -337,7 +346,7 @@ class MemberController( @RequestBody request: SocialLoginRequest ): ApiResponse { if (!authHeader.startsWith("Bearer ")) { - throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") + throw SodaException(messageKey = "member.social.google_login_failed") } val token = authHeader.substring(7) @@ -359,7 +368,8 @@ class MemberController( ) } - return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + val message = messageSource.getMessage("member.signup.success", langContext.lang) + return ApiResponse.ok(message = message, data = response.loginResponse) } @PostMapping("/login/kakao") @@ -368,7 +378,7 @@ class MemberController( @RequestBody request: SocialLoginRequest ): ApiResponse { if (!authHeader.startsWith("Bearer ")) { - throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + throw SodaException(messageKey = "member.social.kakao_login_failed") } val token = authHeader.substring(7) @@ -390,6 +400,7 @@ class MemberController( ) } - return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) + val message = messageSource.getMessage("member.signup.success", langContext.lang) + return ApiResponse.ok(message = message, data = response.loginResponse) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 7b84fac1..f1d34e63 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.order.OrderService import kr.co.vividnext.sodalive.email.SendEmailService import kr.co.vividnext.sodalive.fcm.PushTokenService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser @@ -95,6 +97,9 @@ class MemberService( private val passwordEncoder: PasswordEncoder, private val authenticationManagerBuilder: AuthenticationManagerBuilder, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + private val objectMapper: ObjectMapper, @Value("\${cloud.aws.s3.bucket}") @@ -109,13 +114,13 @@ class MemberService( @Transactional fun signUpV2(request: SignUpRequestV2): SignUpResponse { val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") if (!request.isAgreePrivacyPolicy || !request.isAgreeTermsOfService) { - throw SodaException("약관에 동의하셔야 회원가입이 가능합니다.") + throw SodaException(messageKey = "member.validation.agree_required") } duplicateCheckEmail(request.email) @@ -160,14 +165,14 @@ class MemberService( requestString: String ): SignUpResponse { val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val request = objectMapper.readValue(requestString, SignUpRequest::class.java) if (!request.isAgreePrivacyPolicy || !request.isAgreeTermsOfService) { - throw SodaException("약관에 동의하셔야 회원가입이 가능합니다.") + throw SodaException(messageKey = "member.validation.agree_required") } validatePassword(request.password) @@ -187,14 +192,14 @@ class MemberService( fun login(request: LoginRequest): ApiResponse { return ApiResponse.ok( - message = "로그인 되었습니다.", + message = messageSource.getMessage("member.login.success", langContext.lang), data = login(request.email, request.password, request.isAdmin, request.isCreator) ) } fun getMember(id: Long, container: String): ProfileResponse { val member = repository.findByIdOrNull(id) - ?: throw SodaException("없는 사용자 입니다.") + ?: throw SodaException(messageKey = "member.validation.user_not_found") return ProfileResponse(member, cloudFrontHost, container) } @@ -202,9 +207,9 @@ class MemberService( fun getMemberInfo(member: Member, container: String): GetMemberInfoResponse { val gender = if (member.auth != null) { if (member.auth!!.gender == 1) { - "남" + messageSource.getMessage("member.gender.male", langContext.lang) } else { - "여" + messageSource.getMessage("member.gender.female", langContext.lang) } } else { null @@ -260,7 +265,7 @@ class MemberService( @Transactional fun updateAdid(memberId: Long, adid: String) { val member = repository.findByIdOrNull(id = memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") member.adid = adid } @@ -305,28 +310,24 @@ class MemberService( isAdmin: Boolean = false, isCreator: Boolean = false ): LoginResponse { - val member = repository.findByEmail(email = email) ?: throw SodaException("없는 계정입니다.") + val member = repository.findByEmail(email = email) + ?: throw SodaException(messageKey = "member.validation.account_not_found") if (!member.isActive) { - throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + throw SodaException(messageKey = "member.validation.inactive_account") } if (member.provider != MemberProvider.EMAIL) { - val provider = when (member.provider) { - MemberProvider.APPLE -> "애플" - MemberProvider.GOOGLE -> "구글" - else -> "카카오" - } - - throw SodaException("해당 이메일은 $provider 계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.") + val provider = resolveProviderLabel(member.provider) + throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider)) } if (isCreator && member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } if (isAdmin && member.role != MemberRole.ADMIN) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val authenticationToken = UsernamePasswordAuthenticationToken(email, password) @@ -414,22 +415,17 @@ class MemberService( if (findMember != null) { if (findMember.provider == MemberProvider.EMAIL) { - throw SodaException("이미 사용중인 이메일 입니다.", "email") + throw SodaException(messageKey = "member.validation.email_in_use", errorProperty = "email") } else { - val provider = when (findMember.provider) { - MemberProvider.APPLE -> "애플" - MemberProvider.GOOGLE -> "구글" - else -> "카카오" - } - + val provider = resolveProviderLabel(findMember.provider) throw SodaException( - "해당 이메일은 $provider 계정으로 이미 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.", - "email" + message = formatMessage("member.validation.email_registered_with_provider_already", provider), + errorProperty = "email" ) } } - return ApiResponse.ok(message = "사용 가능한 이메일 입니다.") + return ApiResponse.ok(message = messageSource.getMessage("member.validation.email_available", langContext.lang)) } private fun validateEmail(email: String) { @@ -441,8 +437,9 @@ class MemberService( fun duplicateCheckNickname(nickname: String): ApiResponse { validateNickname(nickname) - repository.findByNickname(nickname)?.let { throw SodaException("이미 사용중인 닉네임 입니다.", "nickname") } - return ApiResponse.ok(message = "사용 가능한 닉네임 입니다.") + repository.findByNickname(nickname) + ?.let { throw SodaException(messageKey = "member.validation.nickname_in_use", errorProperty = "nickname") } + return ApiResponse.ok(message = messageSource.getMessage("member.validation.nickname_available", langContext.lang)) } private fun validateNickname(nickname: String) { @@ -469,8 +466,10 @@ class MemberService( ) if (creatorFollowing == null) { - val creator = repository.findByIdOrNull(creatorId) ?: throw SodaException("크리에이터 정보를 확인해주세요.") - val member = repository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") + val creator = repository.findByIdOrNull(creatorId) + ?: throw SodaException(messageKey = "member.validation.creator_not_found") + val member = repository.findByIdOrNull(memberId) + ?: throw SodaException(messageKey = "common.error.bad_credentials") val newCreatorFollowing = CreatorFollowing() newCreatorFollowing.member = member @@ -514,10 +513,10 @@ class MemberService( if (blockMember == null) { val blockedMember = repository.findByIdOrNull(id = request.blockMemberId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val member = repository.findByIdOrNull(id = memberId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") blockMember = BlockMember() blockMember.member = member @@ -545,7 +544,7 @@ class MemberService( fun searchMember(nickname: String, member: Member): List { if (nickname.length < 2) { - throw SodaException("두 글자 이상 입력 하셔야 합니다.") + throw SodaException(messageKey = "member.validation.nickname_min_length") } return repository.findByNicknameAndOtherCondition(nickname, member) @@ -560,7 +559,7 @@ class MemberService( @Transactional fun logout(token: String, memberId: Long) { val member = repository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") member.pushToken = null pushTokenService.logout(memberId = memberId) @@ -568,7 +567,7 @@ class MemberService( val lock = getOrCreateLock(memberId = memberId) lock.write { val memberToken = tokenRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") memberToken.tokenSet.remove(token) tokenRepository.save(memberToken) @@ -578,7 +577,7 @@ class MemberService( @Transactional fun logoutAll(memberId: Long) { val member = repository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") member.pushToken = null pushTokenService.logout(memberId = memberId) @@ -589,16 +588,17 @@ class MemberService( @Transactional fun signOut(signOutRequest: SignOutRequest, user: User) { - val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = repository.findByEmail(user.username) + ?: throw SodaException(messageKey = "common.error.bad_credentials") if ( member.provider == MemberProvider.EMAIL && !passwordEncoder.matches(signOutRequest.password, member.password) ) { - throw SodaException("비밀번호가 일치하지 않습니다.") + throw SodaException(messageKey = "member.validation.password_mismatch") } if (signOutRequest.reason.isBlank()) { - throw SodaException("탈퇴하려는 이유를 입력해 주세요.") + throw SodaException(messageKey = "member.validation.signout_reason_required") } logoutAll(memberId = member.id!!) @@ -617,15 +617,16 @@ class MemberService( @Transactional fun updateNickname(profileUpdateRequest: ProfileUpdateRequest, user: User) { if (profileUpdateRequest.email != user.username) { - throw SodaException("로그인 정보를 확인해 주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } - val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = repository.findByEmail(user.username) + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (profileUpdateRequest.nickname != null) { validateNickname(profileUpdateRequest.nickname) repository.findByNickname(profileUpdateRequest.nickname) - ?.let { throw SodaException("이미 사용중인 닉네임 입니다.") } + ?.let { throw SodaException(messageKey = "member.validation.nickname_in_use") } val price = repository.getChangeNicknamePrice(memberId = member.id!!).price if (price > 0) { @@ -648,17 +649,18 @@ class MemberService( @Transactional fun profileUpdate(profileUpdateRequest: ProfileUpdateRequest, user: User): ProfileResponse { if (profileUpdateRequest.email != user.username) { - throw SodaException("로그인 정보를 확인해 주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } - val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = repository.findByEmail(user.username) + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (profileUpdateRequest.modifyPassword != null) { if (passwordEncoder.matches(profileUpdateRequest.password, member.password)) { validatePassword(profileUpdateRequest.modifyPassword) member.password = passwordEncoder.encode(profileUpdateRequest.modifyPassword) } else { - throw SodaException("비밀번호가 일치하지 않습니다.") + throw SodaException(messageKey = "member.validation.password_mismatch") } } @@ -669,7 +671,7 @@ class MemberService( if (profileUpdateRequest.nickname != null) { validateNickname(profileUpdateRequest.nickname) repository.findByNickname(profileUpdateRequest.nickname) - ?.let { throw SodaException("이미 사용중인 닉네임 입니다.") } + ?.let { throw SodaException(messageKey = "member.validation.nickname_in_use") } member.nickname = profileUpdateRequest.nickname } @@ -723,7 +725,8 @@ class MemberService( @Transactional fun profileImageUpdate(multipartFile: MultipartFile, user: User): String { - val member = repository.findByEmail(user.username) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = repository.findByEmail(user.username) + ?: throw SodaException(messageKey = "common.error.bad_credentials") val metadata = ObjectMetadata() metadata.contentLength = multipartFile.size @@ -741,17 +744,11 @@ class MemberService( @Transactional fun forgotPassword(request: ForgotPasswordRequest) { val member = repository.getMemberByEmail(email = request.email) - ?: throw SodaException("등록되지 않은 계정입니다.\n확인 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.unregistered_account_retry") - val provider = when (member.provider) { - MemberProvider.EMAIL -> "이메일" - MemberProvider.KAKAO -> "카카오" - MemberProvider.GOOGLE -> "구글" - MemberProvider.APPLE -> "애플" - } - - if (provider != "이메일") { - throw SodaException("해당 계정은 $provider 계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.") + if (member.provider != MemberProvider.EMAIL) { + val provider = resolveProviderLabel(member.provider) + throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider)) } val password = generatePassword(12) @@ -779,6 +776,21 @@ class MemberService( return repository.getMemberProfile(memberId, myMemberId) } + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: "" + return if (args.isEmpty()) template else String.format(template, *args) + } + + private fun resolveProviderLabel(provider: MemberProvider): String { + val key = when (provider) { + MemberProvider.EMAIL -> "member.provider.email" + MemberProvider.KAKAO -> "member.provider.kakao" + MemberProvider.GOOGLE -> "member.provider.google" + MemberProvider.APPLE -> "member.provider.apple" + } + return messageSource.getMessage(key, langContext.lang) ?: provider.name + } + private fun getOrCreateLock(memberId: Long): ReentrantReadWriteLock { return tokenLocks.computeIfAbsent(memberId) { ReentrantReadWriteLock() } } @@ -786,7 +798,7 @@ class MemberService( @Transactional fun updateMarketingInfo(memberId: Long, adid: String, pid: String): String? { val member = repository.findByIdOrNull(id = memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (adid != member.adid) { member.adid = adid @@ -814,15 +826,15 @@ class MemberService( if (findMember.isActive) { return MemberResolveResult(member = findMember, isNew = false) } else { - throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + throw SodaException(messageKey = "member.validation.inactive_account") } } val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val email = googleUserInfo.email checkEmail(email) @@ -870,15 +882,15 @@ class MemberService( if (findMember.isActive) { return MemberResolveResult(member = findMember, isNew = false) } else { - throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + throw SodaException(messageKey = "member.validation.inactive_account") } } val stipulationTermsOfService = stipulationRepository.findByIdOrNull(StipulationIds.TERMS_OF_SERVICE_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val stipulationPrivacyPolicy = stipulationRepository.findByIdOrNull(StipulationIds.PRIVACY_POLICY_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") val email = kakaoUserInfo.email checkEmail(email) @@ -918,13 +930,8 @@ class MemberService( val member = repository.findByEmail(email) if (member != null) { - val provider = when (member.provider) { - MemberProvider.APPLE -> "애플" - MemberProvider.GOOGLE -> "구글" - else -> "카카오" - } - - throw SodaException("해당 이메일은 $provider 계정으로 가입되어 있습니다. 해당 소셜 로그인을 사용해 주세요.") + val provider = resolveProviderLabel(member.provider) + throw SodaException(message = formatMessage("member.validation.email_registered_with_provider", provider)) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt index b5b92981..8271e953 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -22,13 +22,13 @@ class AuthController( @RequestBody request: AuthVerifyRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val authenticateData = service.certificate(request, memberId = member.id!!) if (service.isBlockAuth(authenticateData)) { service.signOut(member.id!!) - throw SodaException("운영정책을 위반하여 이용을 제한합니다.") + throw SodaException(messageKey = "member.auth.blocked_policy") } val authResponse = service.authenticate(authenticateData, member.id!!) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt index e736e1be..16800e38 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthService.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.member.auth import com.fasterxml.jackson.databind.ObjectMapper import kr.co.bootpay.Bootpay import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.SignOut @@ -22,6 +24,8 @@ class AuthService( private val memberService: MemberService, private val memberRepository: MemberRepository, private val signOutRepository: SignOutRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${bootpay.application-id}") private val bootpayApplicationId: String, @@ -32,16 +36,16 @@ class AuthService( val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) val authId = repository.getAuthIdByMemberId(memberId = memberId) - if (authId != null) throw SodaException("이미 인증된 계정입니다.") + if (authId != null) throw SodaException(messageKey = "member.auth.already_verified") val certificateResult: AuthCertificateResult = try { val token = bootpay.accessToken - if (token["error_code"] != null) throw SodaException("인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + if (token["error_code"] != null) throw SodaException(messageKey = "member.auth.certificate_invalid_retry") val res = bootpay.certificate(request.receiptId) objectMapper.convertValue(res, AuthCertificateResult::class.java) } catch (e: Exception) { - throw SodaException(e.message ?: "인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "member.auth.certificate_invalid_retry") } if ( @@ -51,7 +55,7 @@ class AuthService( ) { return certificateResult.authenticateData } else { - throw SodaException("인증정보에 오류가 있습니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "member.auth.certificate_invalid_retry") } } @@ -62,11 +66,13 @@ class AuthService( @Transactional fun signOut(memberId: Long) { - val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = memberRepository.findByIdOrNull(memberId) + ?: throw SodaException(messageKey = "common.error.bad_credentials") member.isActive = false member.nickname = "deleted_${member.nickname}" - val signOut = SignOut(reason = "운영정책을 위반하여 이용을 제한합니다.") + val signOutReason = messageSource.getMessage("member.auth.blocked_policy", langContext.lang) ?: "" + val signOut = SignOut(reason = signOutReason) signOut.member = member signOutRepository.save(signOut) @@ -77,13 +83,14 @@ class AuthService( fun authenticate(certificate: AuthVerifyCertificate, memberId: Long): AuthResponse { val memberIds = repository.getActiveMemberIdsByDi(di = certificate.di) if (memberIds.size >= 3) { + val message = messageSource.getMessage("member.auth.max_accounts", langContext.lang) ?: "" throw SodaException( - "이미 본인인증한 계정 ${memberIds.size}개 이용중입니다.\n" + - "소다라이브의 본인인증은 최대 3개의 계정만 이용할 수 있습니다." + message = String.format(message, memberIds.size) ) } - val member = memberRepository.findByIdOrNull(memberId) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = memberRepository.findByIdOrNull(memberId) + ?: throw SodaException(messageKey = "common.error.bad_credentials") val nowYear = LocalDate.now().year val certificateYear = certificate.birth.substring(0, 4).toInt() if (nowYear - certificateYear >= 19) { @@ -99,7 +106,8 @@ class AuthService( repository.save(auth) return AuthResponse(gender = certificate.gender) } else { - throw SodaException("${nowYear - 19}년 1월 1일 이전 출생자만 본인인증이 가능합니다.") + val message = messageSource.getMessage("member.auth.age_limit", langContext.lang) ?: "" + throw SodaException(message = String.format(message, nowYear - 19)) } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/nickname/NicknameGenerateService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/nickname/NicknameGenerateService.kt index 341569cd..77002bca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/nickname/NicknameGenerateService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/nickname/NicknameGenerateService.kt @@ -67,7 +67,7 @@ class NicknameGenerateService(private val repository: MemberRepository) { } } } - throw SodaException("회원가입을 하지 못했습니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "member.signup.failed_retry") } fun generateUniqueNickname(): String { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt index 46548bc6..99530a6a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleAuthService.kt @@ -26,7 +26,7 @@ class GoogleAuthService( pushToken: String? ): SocialLoginResponse { val googleUserInfo = googleService.getUserInfo(idToken) - ?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") + ?: throw SodaException(messageKey = "member.social.google_login_failed") val memberResolveResult = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken) val member = memberResolveResult.member val principal = MemberAdapter(member) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt index fa313951..e6ac4088 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/google/GoogleService.kt @@ -27,7 +27,7 @@ class GoogleService( if (token != null) { val payload = token.payload - val email = payload.email ?: throw SodaException("이메일 제공에 동의하셔야 서비스 이용이 가능합니다.") + val email = payload.email ?: throw SodaException(messageKey = "member.social.email_consent_required") GoogleUserInfo( sub = payload.subject, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt index f8e66ba1..ed4cf0c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoAuthService.kt @@ -26,7 +26,7 @@ class KakaoAuthService( pushToken: String? ): SocialLoginResponse { val kakaoUserInfo = kakaoService.getUserInfo(accessToken) - ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + ?: throw SodaException(messageKey = "member.social.kakao_login_failed") val memberResolveResult = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken) val member = memberResolveResult.member val principal = MemberAdapter(member) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt index ffd12049..8ad65c56 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/social/kakao/KakaoService.kt @@ -37,7 +37,7 @@ class KakaoService( val id = jsonNode.get("id").asLong() val kakaoAccount = jsonNode.get("kakao_account") val email = kakaoAccount?.get("email")?.asText() - ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") + ?: throw SodaException(messageKey = "member.social.kakao_login_failed") val properties = jsonNode.get("properties") val nickname = properties?.get("nickname")?.asText() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt index 617b0719..19280080 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/stipulation/StipulationService.kt @@ -16,14 +16,14 @@ class StipulationService(private val repository: StipulationRepository) { fun getTermsOfService(): StipulationDto { val stipulation = repository.findByIdOrNull(TERMS_OF_SERVICE_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") return StipulationDto(stipulation) } fun getPrivacyPolicy(): StipulationDto { val stipulation = repository.findByIdOrNull(PRIVACY_POLICY_ID) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") return StipulationDto(stipulation) } @@ -31,7 +31,7 @@ class StipulationService(private val repository: StipulationRepository) { @Transactional fun modify(request: StipulationModifyRequest) { val stipulation = repository.findByIdOrNull(request.id) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "member.validation.invalid_request_retry") stipulation.description = request.description } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt index e46a27ca..20380c7c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/tag/MemberTagController.kt @@ -15,7 +15,7 @@ class MemberTagController(private val service: MemberTagService) { fun getTags( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getTags(member)) } -- 2.49.1 From fd94df338bf6394fa940bf0803dd7a103d579e5a Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 13:52:53 +0900 Subject: [PATCH 74/90] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=ED=83=9C=EA=B7=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/i18n/SodaMessageSource.kt | 83 +++++++++++++++++++ .../live/roulette/NewRouletteController.kt | 12 +-- .../live/roulette/NewRouletteService.kt | 37 +++++---- .../live/roulette/v2/RouletteController.kt | 12 +-- .../live/roulette/v2/RouletteService.kt | 42 ++++++---- .../sodalive/live/tag/LiveTagController.kt | 30 +++++-- .../sodalive/live/tag/LiveTagService.kt | 8 +- 7 files changed, 172 insertions(+), 52 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 49e52017..bacf5cf5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -626,6 +626,87 @@ class SodaMessageSource { ) ) + private val liveRouletteMessages = mapOf( + "live.roulette.unavailable" to mapOf( + Lang.KO to "룰렛을 사용할 수 없습니다.", + Lang.EN to "Roulette is unavailable.", + Lang.JA to "ルーレットを使用できません。" + ), + "live.roulette.live_not_found" to mapOf( + Lang.KO to "해당하는 라이브가 없습니다.", + Lang.EN to "Live session not found.", + Lang.JA to "該当するライブがありません。" + ), + "live.roulette.live_info_not_found" to mapOf( + Lang.KO to "해당하는 라이브의 정보가 없습니다.", + Lang.EN to "Live session information not found.", + Lang.JA to "該当するライブの情報がありません。" + ), + "live.roulette.creator_contract_only" to mapOf( + Lang.KO to "주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.", + Lang.EN to "Only roulette from creators contracted with Sodalive Co., Ltd. can be used.", + Lang.JA to "株式会社ソダライブと契約した\nクリエイターのルーレットのみ利用できます。" + ), + "live.roulette.refund_failed" to mapOf( + Lang.KO to "룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.", + Lang.EN to "Cans from the failed roulette spin have not been refunded.\nPlease contact customer support.", + Lang.JA to "ルーレットの失敗分の缶が返金されていません。\nカスタマーサポートへお問い合わせください。" + ), + "live.roulette.min_can" to mapOf( + Lang.KO to "룰렛 금액은 최소 5캔 입니다.", + Lang.EN to "Roulette cost is at least 5 cans.", + Lang.JA to "ルーレット金額は最低5缶です。" + ), + "live.roulette.item_count_range" to mapOf( + Lang.KO to "룰렛 옵션은 최소 2개, 최대 10개까지 설정할 수 있습니다.", + Lang.EN to "Roulette options must be between 2 and 10 items.", + Lang.JA to "ルーレットのオプションは最小2個、最大10個まで設定できます。" + ), + "live.roulette.probability_invalid" to mapOf( + Lang.KO to "확률이 100%가 아닙니다", + Lang.EN to "The probability is not 100%.", + Lang.JA to "確率が100%ではありません。" + ), + "live.roulette.result_message" to mapOf( + Lang.KO to "[%s] 당첨!", + Lang.EN to "[%s] Won!", + Lang.JA to "[%s] 当選!" + ), + "live.roulette.can_title" to mapOf( + Lang.KO to "%s 캔", + Lang.EN to "%s cans", + Lang.JA to "%s 缶" + ), + "live.roulette.refund_method" to mapOf( + Lang.KO to "룰렛 환불", + Lang.EN to "Roulette refund", + Lang.JA to "ルーレット返金" + ) + ) + + private val liveTagMessages = mapOf( + "live.tag.registered" to mapOf( + Lang.KO to "등록되었습니다.", + Lang.EN to "Successfully registered.", + Lang.JA to "登録されました。" + ), + "live.tag.deleted" to mapOf( + Lang.KO to "삭제되었습니다.", + Lang.EN to "Deleted.", + Lang.JA to "削除されました。" + ), + "live.tag.updated" to mapOf( + Lang.KO to "수정되었습니다.", + Lang.EN to "Updated.", + Lang.JA to "更新されました。" + ), + "live.tag.duplicate" to mapOf( + Lang.KO to "이미 등록된 태그 입니다.", + Lang.EN to "Tag already registered.", + Lang.JA to "すでに登録されたタグです。" + ) + ) + private val memberProviderMessages = mapOf( "member.provider.email" to mapOf( Lang.KO to "이메일", @@ -692,6 +773,8 @@ class SodaMessageSource { memberMessages, memberValidationMessages, memberSocialMessages, + liveRouletteMessages, + liveTagMessages, memberProviderMessages, memberGenderMessages ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteController.kt index 9464285c..5509cff0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteController.kt @@ -24,7 +24,7 @@ class NewRouletteController(private val service: NewRouletteService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getAllRoulette(creatorId = creatorId, memberId = member.id!!)) } @@ -36,7 +36,7 @@ class NewRouletteController(private val service: NewRouletteService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null || member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.createRoulette(memberId = member.id!!, request = request)) @@ -49,7 +49,7 @@ class NewRouletteController(private val service: NewRouletteService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null || member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.updateRoulette(memberId = member.id!!, request = request)) @@ -60,7 +60,7 @@ class NewRouletteController(private val service: NewRouletteService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") return ApiResponse.ok(service.getRoulette(creatorId = creatorId, memberId = member.id!!)) } @@ -70,7 +70,7 @@ class NewRouletteController(private val service: NewRouletteService) { @RequestBody request: SpinRouletteRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.spinRoulette(request = request, memberId = member.id!!)) } @@ -80,7 +80,7 @@ class NewRouletteController(private val service: NewRouletteService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.refundDonation(id, member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteService.kt index ef8ec39a..e9609f95 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/NewRouletteService.kt @@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository @@ -24,6 +26,8 @@ import org.springframework.transaction.annotation.Transactional class NewRouletteService( private val idGenerator: RedisIdGenerator, private val canPaymentService: CanPaymentService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, private val canRepository: CanRepository, private val repository: NewRouletteRepository, @@ -33,7 +37,7 @@ class NewRouletteService( private val useCanCalculateRepository: UseCanCalculateRepository ) { fun getAllRoulette(creatorId: Long, memberId: Long): List { - if (creatorId != memberId) throw SodaException("잘못된 요청입니다.") + if (creatorId != memberId) throw SodaException(messageKey = "common.error.invalid_request") val rouletteList = repository.findByCreatorId(creatorId) @@ -77,7 +81,7 @@ class NewRouletteService( val rouletteList = repository.findByCreatorId(creatorId = memberId) if (rouletteList.isEmpty()) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } var isActive = false @@ -104,7 +108,7 @@ class NewRouletteService( val rouletteList = repository.findByCreatorId(creatorId = creatorId) if (rouletteList.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } var activeRoulette: NewRoulette? = null @@ -116,7 +120,7 @@ class NewRouletteService( } if (activeRoulette == null || activeRoulette.items.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } return GetRouletteResponse( @@ -130,19 +134,19 @@ class NewRouletteService( fun spinRoulette(request: SpinRouletteRequest, memberId: Long): GetRouletteResponse { // STEP 1 - 라이브 정보 가져오기 val room = roomRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.roulette.live_not_found") - val host = room.member ?: throw SodaException("잘못된 요청입니다.") + val host = room.member ?: throw SodaException(messageKey = "common.error.invalid_request") if (host.role != MemberRole.CREATOR) { - throw SodaException("주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.") + throw SodaException(messageKey = "live.roulette.creator_contract_only") } // STEP 2 - 룰렛 데이터 가져오기 val rouletteList = repository.findByCreatorId(creatorId = host.id!!) if (rouletteList.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } var activeRoulette: NewRoulette? = null @@ -154,7 +158,7 @@ class NewRouletteService( } if (activeRoulette == null || activeRoulette.items.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } // STEP 3 - 캔 사용 @@ -176,20 +180,23 @@ class NewRouletteService( @Transactional fun refundDonation(roomId: Long, member: Member) { val donator = memberRepository.findByIdOrNull(member.id) - ?: throw SodaException("룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ?: throw SodaException(messageKey = "live.roulette.refund_failed") val useCan = canRepository.getCanUsedForLiveRoomNotRefund( memberId = member.id!!, roomId = roomId, canUsage = CanUsage.SPIN_ROULETTE - ) ?: throw SodaException("룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ) ?: throw SodaException(messageKey = "live.roulette.refund_failed") useCan.isRefund = true val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) useCanCalculates.forEach { it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + val canTitleTemplate = messageSource + .getMessage("live.roulette.can_title", langContext.lang) + .orEmpty() + charge.title = String.format(canTitleTemplate, it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -203,7 +210,7 @@ class NewRouletteService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "룰렛 환불" + payment.method = messageSource.getMessage("live.roulette.refund_method", langContext.lang).orEmpty() charge.payment = payment chargeRepository.save(charge) @@ -212,11 +219,11 @@ class NewRouletteService( private fun rouletteValidate(can: Int, items: List) { if (can < 5) { - throw SodaException("룰렛 금액은 최소 5캔 입니다.") + throw SodaException(messageKey = "live.roulette.min_can") } if (items.size < 2 || items.size > 10) { - throw SodaException("룰렛 옵션은 최소 2개, 최대 10개까지 설정할 수 있습니다.") + throw SodaException(messageKey = "live.roulette.item_count_range") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteController.kt index e201430d..05708ba2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteController.kt @@ -24,7 +24,7 @@ class RouletteController(private val service: RouletteService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getAllRoulette(creatorId = creatorId, memberId = member.id!!)) } @@ -36,7 +36,7 @@ class RouletteController(private val service: RouletteService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null || member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.createRoulette(memberId = member.id!!, request = request)) @@ -49,7 +49,7 @@ class RouletteController(private val service: RouletteService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null || member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.updateRoulette(memberId = member.id!!, request = request)) @@ -60,7 +60,7 @@ class RouletteController(private val service: RouletteService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getRoulette(creatorId = creatorId, memberId = member.id!!)) } @@ -70,7 +70,7 @@ class RouletteController(private val service: RouletteService) { @RequestBody request: SpinRouletteRequestV2, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.spinRoulette(request = request, member = member)) } @@ -80,7 +80,7 @@ class RouletteController(private val service: RouletteService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.refundDonation(id, member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteService.kt index 303c89e9..6c24c88f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/roulette/v2/RouletteService.kt @@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository import kr.co.vividnext.sodalive.live.roulette.NewRoulette @@ -31,6 +33,8 @@ import kotlin.random.Random class RouletteService( private val idGenerator: RedisIdGenerator, private val canPaymentService: CanPaymentService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, private val canRepository: CanRepository, private val repository: RouletteRepository, @@ -43,7 +47,7 @@ class RouletteService( private val tokenLocks: MutableMap = mutableMapOf() fun getAllRoulette(creatorId: Long, memberId: Long): List { - if (creatorId != memberId) throw SodaException("잘못된 요청입니다.") + if (creatorId != memberId) throw SodaException(messageKey = "common.error.invalid_request") return repository.findByCreatorId(creatorId) .sortedBy { it.id } @@ -88,7 +92,7 @@ class RouletteService( val rouletteList = repository.findByCreatorId(creatorId = memberId) if (rouletteList.isEmpty()) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } var activeRoulette = false @@ -119,7 +123,7 @@ class RouletteService( .map { GetRouletteResponseV2(it.id, it.can, it.isActive, it.items) } if (rouletteList.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } return rouletteList @@ -129,19 +133,19 @@ class RouletteService( fun spinRoulette(request: SpinRouletteRequestV2, member: Member): SpinRouletteResponse { // STEP 1 - 라이브 정보 가져오기 val room = roomRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.roulette.live_not_found") - val host = room.member ?: throw SodaException("잘못된 요청입니다.") + val host = room.member ?: throw SodaException(messageKey = "common.error.invalid_request") if (host.role != MemberRole.CREATOR) { - throw SodaException("주식회사 소다라이브와 계약한\n크리에이터의 룰렛만 사용하실 수 있습니다.") + throw SodaException(messageKey = "live.roulette.creator_contract_only") } // STEP 2 - 룰렛 데이터 가져오기 val roulette = repository.findByIdOrNull(id = request.rouletteId) if (roulette == null || roulette.items.isEmpty()) { - throw SodaException("룰렛을 사용할 수 없습니다.") + throw SodaException(messageKey = "live.roulette.unavailable") } // STEP 3 - 캔 사용 @@ -159,12 +163,15 @@ class RouletteService( val lock = getOrCreateLock(memberId = member.id!!) lock.write { val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.roulette.live_info_not_found") + val messageTemplate = messageSource + .getMessage("live.roulette.result_message", langContext.lang) + .orEmpty() roomInfo.addRouletteMessage( memberId = member.id!!, nickname = member.nickname, - donationMessage = "[$result] 당첨!" + donationMessage = String.format(messageTemplate, result) ) roomInfoRepository.save(roomInfo) @@ -176,20 +183,23 @@ class RouletteService( @Transactional fun refundDonation(roomId: Long, member: Member) { val donator = memberRepository.findByIdOrNull(member.id) - ?: throw SodaException("룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ?: throw SodaException(messageKey = "live.roulette.refund_failed") val useCan = canRepository.getCanUsedForLiveRoomNotRefund( memberId = member.id!!, roomId = roomId, canUsage = CanUsage.SPIN_ROULETTE - ) ?: throw SodaException("룰렛 돌리기에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ) ?: throw SodaException(messageKey = "live.roulette.refund_failed") useCan.isRefund = true val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) useCanCalculates.forEach { it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + val canTitleTemplate = messageSource + .getMessage("live.roulette.can_title", langContext.lang) + .orEmpty() + charge.title = String.format(canTitleTemplate, it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -203,7 +213,7 @@ class RouletteService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "룰렛 환불" + payment.method = messageSource.getMessage("live.roulette.refund_method", langContext.lang).orEmpty() charge.payment = payment chargeRepository.save(charge) @@ -234,16 +244,16 @@ class RouletteService( private fun rouletteValidate(can: Int, items: List) { if (can < 5) { - throw SodaException("룰렛 금액은 최소 5캔 입니다.") + throw SodaException(messageKey = "live.roulette.min_can") } if (items.size < 2 || items.size > 10) { - throw SodaException("룰렛 옵션은 최소 2개, 최대 10개까지 설정할 수 있습니다.") + throw SodaException(messageKey = "live.roulette.item_count_range") } val totalPercentage = items.map { it.percentage }.sum() if (totalPercentage > 100.1f || totalPercentage <= 99.99f) { - throw SodaException("확률이 100%가 아닙니다") + throw SodaException(messageKey = "live.roulette.probability_invalid") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt index 714f922b..d728f61a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagController.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.live.tag import kr.co.vividnext.sodalive.admin.member.tag.UpdateTagOrdersRequest import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -19,17 +21,27 @@ import org.springframework.web.multipart.MultipartFile @RestController @RequestMapping("/live/tag") -class LiveTagController(private val service: LiveTagService) { +class LiveTagController( + private val service: LiveTagService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @PostMapping @PreAuthorize("hasRole('ADMIN')") fun enrollmentLiveTag( @RequestPart("image") image: MultipartFile, @RequestPart("request") requestString: String - ) = ApiResponse.ok(service.enrollmentLiveTag(image, requestString), "등록되었습니다.") + ) = ApiResponse.ok( + service.enrollmentLiveTag(image, requestString), + messageSource.getMessage("live.tag.registered", langContext.lang) + ) @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") - fun deleteLiveTag(@PathVariable id: Long) = ApiResponse.ok(service.deleteTag(id), "삭제되었습니다.") + fun deleteLiveTag(@PathVariable id: Long) = ApiResponse.ok( + service.deleteTag(id), + messageSource.getMessage("live.tag.deleted", langContext.lang) + ) @PutMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") @@ -37,20 +49,26 @@ class LiveTagController(private val service: LiveTagService) { @PathVariable id: Long, @RequestPart("image") image: MultipartFile?, @RequestPart("request") requestString: String - ) = ApiResponse.ok(service.modifyTag(id, image, requestString), "수정되었습니다.") + ) = ApiResponse.ok( + service.modifyTag(id, image, requestString), + messageSource.getMessage("live.tag.updated", langContext.lang) + ) @PutMapping("/orders") @PreAuthorize("hasRole('ADMIN')") fun updateTagOrders( @RequestBody request: UpdateTagOrdersRequest - ) = ApiResponse.ok(service.updateTagOrders(request.ids), "수정되었습니다.") + ) = ApiResponse.ok( + service.updateTagOrders(request.ids), + messageSource.getMessage("live.tag.updated", langContext.lang) + ) @GetMapping fun getTags( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getTags(member)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt index 31cd5e39..f97d887f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/tag/LiveTagService.kt @@ -48,7 +48,7 @@ class LiveTagService( @Transactional fun deleteTag(id: Long) { val tag = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") tag.tag = "${tag.tag}_deleted" tag.isActive = false @@ -57,7 +57,7 @@ class LiveTagService( @Transactional fun modifyTag(id: Long, image: MultipartFile?, requestString: String) { val tag = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val request = objectMapper.readValue(requestString, CreateLiveTagRequest::class.java) tag.tag = request.tag @@ -95,6 +95,8 @@ class LiveTagService( } fun tagExistCheck(request: CreateLiveTagRequest) { - repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") } + repository.findByTag(request.tag)?.let { + throw SodaException(messageKey = "live.tag.duplicate") + } } } -- 2.49.1 From 39d13ab7c3c0058658321692c986e1cfc09807d7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 14:20:52 +0900 Subject: [PATCH 75/90] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=A3=B8=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/i18n/SodaMessageSource.kt | 178 +++++++++++++++ .../sodalive/live/room/LiveRoomController.kt | 48 ++-- .../sodalive/live/room/LiveRoomService.kt | 205 +++++++++++------- .../room/kickout/LiveRoomKickOutController.kt | 2 +- .../room/kickout/LiveRoomKickOutService.kt | 8 +- .../live/room/menu/LiveRoomMenuController.kt | 4 +- .../live/room/menu/LiveRoomMenuService.kt | 8 +- 7 files changed, 339 insertions(+), 114 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index bacf5cf5..8e7d95dd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -707,6 +707,177 @@ class SodaMessageSource { ) ) + private val liveRoomMessages = mapOf( + "live.room.max_reservations" to mapOf( + Lang.KO to "예약 라이브는 최대 3개까지 가능합니다.", + Lang.EN to "You can reserve up to 3 live sessions.", + Lang.JA to "予約ライブは最大3件まで可能です。" + ), + "live.room.cover_image_required" to mapOf( + Lang.KO to "커버이미지를 선택해 주세요.", + Lang.EN to "Please select a cover image.", + Lang.JA to "カバー画像を選択してください。" + ), + "live.room.start_time_minimum" to mapOf( + Lang.KO to "현재시각 기준, 30분 이후부터 설정가능합니다.", + Lang.EN to "You can set it from 30 minutes after the current time.", + Lang.JA to "現在時刻から30分後以降に設定できます。" + ), + "live.room.password_required" to mapOf( + Lang.KO to "방 입장 비밀번호 6자리를 입력해 주세요.", + Lang.EN to "Please enter a 6-digit room password.", + Lang.JA to "入室パスワード6桁を入力してください。" + ), + "live.room.paid_min_can" to mapOf( + Lang.KO to "유료라이브는 10캔부터 설정 가능 합니다.", + Lang.EN to "Paid live can be set from 10 cans.", + Lang.JA to "有料ライブは10缶から設定できます。" + ), + "live.room.already_ended" to mapOf( + Lang.KO to "이미 종료된 방입니다.", + Lang.EN to "This room has already ended.", + Lang.JA to "すでに終了したルームです。" + ), + "live.room.adult_verification_required" to mapOf( + Lang.KO to "본인인증이 필요한 서비스 입니다.", + Lang.EN to "This service requires identity verification.", + Lang.JA to "本人認証が必要なサービスです。" + ), + "live.room.not_found" to mapOf( + Lang.KO to "해당하는 라이브가 없습니다.", + Lang.EN to "Live session not found.", + Lang.JA to "該当するライブがありません。" + ), + "live.room.start_available_after" to mapOf( + Lang.KO to "%s 이후에 시작할 수 있습니다.", + Lang.EN to "You can start after %s.", + Lang.JA to "%s以降に開始できます。" + ), + "live.room.cancel_reason_required" to mapOf( + Lang.KO to "취소사유를 입력해 주세요.", + Lang.EN to "Please enter a cancellation reason.", + Lang.JA to "キャンセル理由を入力してください。" + ), + "live.room.password_mismatch" to mapOf( + Lang.KO to "비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.", + Lang.EN to "Password does not match.\nPlease check and try again.", + Lang.JA to "パスワードが一致しません。\n確認して入力してください。" + ), + "live.room.enter_blocked_by_host" to mapOf( + Lang.KO to "%s님의 요청으로 라이브에 입장할 수 없습니다.", + Lang.EN to "You cannot enter the live at %s's request.", + Lang.JA to "%sの要請によりライブに入場できません。" + ), + "live.room.participation_blocked_by_host" to mapOf( + Lang.KO to "%s님의 요청으로 라이브에 참여할 수 없습니다.", + Lang.EN to "You cannot participate in the live at %s's request.", + Lang.JA to "%sの要請によりライブに参加できません。" + ), + "live.room.full" to mapOf( + Lang.KO to "방이 가득찼습니다.", + Lang.EN to "The room is full.", + Lang.JA to "ルームが満員です。" + ), + "live.room.insufficient_can" to mapOf( + Lang.KO to "%d캔이 부족합니다. 충전 후 이용해 주세요.", + Lang.EN to "You need %d more cans. Please top up and try again.", + Lang.JA to "%d缶が不足しています。チャージしてご利用ください。" + ), + "live.room.recent_not_found" to mapOf( + Lang.KO to "최근 데이터가 없습니다.", + Lang.EN to "No recent data found.", + Lang.JA to "最近のデータがありません。" + ), + "live.room.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "There are no changes.", + Lang.JA to "変更事項がありません。" + ), + "live.room.info_not_found" to mapOf( + Lang.KO to "해당하는 라이브의 정보가 없습니다.", + Lang.EN to "Live session information not found.", + Lang.JA to "該当するライブの情報がありません。" + ), + "live.room.speaker_limit_exceeded" to mapOf( + Lang.KO to "스피커 정원이 초과하였습니다.", + Lang.EN to "Speaker capacity exceeded.", + Lang.JA to "スピーカーの定員を超えました。" + ), + "live.room.user_not_found" to mapOf( + Lang.KO to "해당하는 유저가 없습니다.", + Lang.EN to "User not found.", + Lang.JA to "該当するユーザーがいません。" + ), + "live.room.already_manager" to mapOf( + Lang.KO to "이미 매니저 입니다.", + Lang.EN to "Already a manager.", + Lang.JA to "すでにマネージャーです。" + ), + "live.room.creator_contract_only_donation" to mapOf( + Lang.KO to "주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.", + Lang.EN to "You can only donate to creators contracted with Sodalive Co., Ltd.", + Lang.JA to "株式会社ソダライブと契約した\nクリエイターにのみ支援できます。" + ), + "live.room.donation_refund_failed" to mapOf( + Lang.KO to "후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.", + Lang.EN to "Cans from the failed donation have not been refunded.\nPlease contact customer support.", + Lang.JA to "支援失敗分の缶が返金されていません。\nカスタマーサポートへお問い合わせください。" + ), + "live.room.datetime_format" to mapOf( + Lang.KO to "yyyy년 MM월 dd일 (E) a hh시 mm분", + Lang.EN to "yyyy MMM dd (EEE) h:mm a", + Lang.JA to "yyyy年 MM月 dd日 (E) a hh時 mm分" + ), + "live.room.datetime_format_detail" to mapOf( + Lang.KO to "yyyy.MM.dd E hh:mm a", + Lang.EN to "yyyy.MM.dd E hh:mm a", + Lang.JA to "yyyy.MM.dd E hh:mm a" + ), + "live.room.fcm.message.started" to mapOf( + Lang.KO to "라이브를 시작했습니다. - %s", + Lang.EN to "Live started. - %s", + Lang.JA to "ライブを開始しました。 - %s" + ), + "live.room.fcm.message.reserved" to mapOf( + Lang.KO to "라이브를 예약했습니다. - %s", + Lang.EN to "Live reserved. - %s", + Lang.JA to "ライブを予約しました。 - %s" + ), + "live.room.fcm.message.started_now" to mapOf( + Lang.KO to "라이브를 시작했습니다 - %s", + Lang.EN to "Live started - %s", + Lang.JA to "ライブを開始しました - %s" + ), + "live.room.fcm.message.canceled" to mapOf( + Lang.KO to "라이브 취소 : %s", + Lang.EN to "Live canceled: %s", + Lang.JA to "ライブ取消: %s" + ), + "live.room.can_title" to mapOf( + Lang.KO to "%d 캔", + Lang.EN to "%d cans", + Lang.JA to "%d 缶" + ), + "live.room.refund_method" to mapOf( + Lang.KO to "환불", + Lang.EN to "Refund", + Lang.JA to "返金" + ) + ) + + private val liveRoomMenuMessages = mapOf( + "live.room.menu.max_count" to mapOf( + Lang.KO to "메뉴판의 최대개수는 3개입니다.", + Lang.EN to "Menu presets are limited to 3.", + Lang.JA to "メニューボードは最大3個までです。" + ), + "live.room.menu.blank_not_allowed" to mapOf( + Lang.KO to "메뉴판은 빈칸일 수 없습니다.", + Lang.EN to "Menu cannot be blank.", + Lang.JA to "メニューボードは空欄にできません。" + ) + ) + private val memberProviderMessages = mapOf( "member.provider.email" to mapOf( Lang.KO to "이메일", @@ -740,6 +911,11 @@ class SodaMessageSource { Lang.KO to "여", Lang.EN to "Female", Lang.JA to "女性" + ), + "member.gender.unknown" to mapOf( + Lang.KO to "미", + Lang.EN to "Unknown", + Lang.JA to "不明" ) ) @@ -775,6 +951,8 @@ class SodaMessageSource { memberSocialMessages, liveRouletteMessages, liveTagMessages, + liveRoomMessages, + liveRoomMenuMessages, memberProviderMessages, memberGenderMessages ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt index c33c5beb..5598a09c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt @@ -56,7 +56,7 @@ class LiveRoomController( @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.createLiveRoom(coverImage, requestString, member)) } @@ -67,7 +67,7 @@ class LiveRoomController( @RequestParam timezone: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getRoomDetail(id, member, timezone)) } @@ -77,7 +77,7 @@ class LiveRoomController( @RequestBody request: EnterOrQuitLiveRoomRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.enterLive(request, member)) } @@ -87,7 +87,7 @@ class LiveRoomController( @RequestBody request: StartLiveRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.startLive(request, member)) } @@ -97,7 +97,7 @@ class LiveRoomController( @RequestBody request: CancelLiveRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.cancelLive(request, member)) } @@ -106,7 +106,7 @@ class LiveRoomController( fun getRecentRoomInfo( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getRecentRoomInfo(member)) } @@ -118,7 +118,7 @@ class LiveRoomController( @RequestPart("request") requestString: String?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.editLiveRoomInfo(roomId, coverImage, requestString, member)) } @@ -128,7 +128,7 @@ class LiveRoomController( @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getRoomInfo(roomId = id, member)) } @@ -138,7 +138,7 @@ class LiveRoomController( @RequestParam roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getDonationMessageList(roomId, member)) } @@ -148,7 +148,7 @@ class LiveRoomController( @RequestBody request: DeleteLiveRoomDonationMessage, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.deleteDonationMessage(request, member)) } @@ -159,7 +159,7 @@ class LiveRoomController( @PathVariable("user_id") userId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getUserProfile(roomId, userId, member)) } @@ -169,7 +169,7 @@ class LiveRoomController( @PathVariable("id") roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getDonationTotal(roomId)) } @@ -179,7 +179,7 @@ class LiveRoomController( @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.setSpeaker(request)) } @@ -189,7 +189,7 @@ class LiveRoomController( @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.setListener(request)) } @@ -199,7 +199,7 @@ class LiveRoomController( @RequestBody request: SetManagerOrSpeakerOrAudienceRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.setManager(request, member)) } @@ -209,7 +209,7 @@ class LiveRoomController( @RequestBody request: LiveRoomDonationRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.donation(request, member)) } @@ -219,7 +219,7 @@ class LiveRoomController( @RequestBody request: LiveRoomDonationRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.donationV2(request, member)) } @@ -229,7 +229,7 @@ class LiveRoomController( @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.refundDonation(id, member)) } @@ -239,7 +239,7 @@ class LiveRoomController( @PathVariable("id") roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getDonationStatus(roomId, memberId = member.id!!)) } @@ -249,7 +249,7 @@ class LiveRoomController( @RequestParam("id") roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.quitRoom(roomId, member)) } @@ -257,7 +257,7 @@ class LiveRoomController( fun recentVisitRoomUsers( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(visitService.getRecentVisitRoomUsers(member)) } @@ -267,7 +267,7 @@ class LiveRoomController( @RequestBody request: LiveRoomLikeHeartRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.likeHeart(request, member)) } @@ -277,7 +277,7 @@ class LiveRoomController( @PathVariable("id") roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getTotalHeartCount(roomId)) } @@ -287,7 +287,7 @@ class LiveRoomController( @PathVariable("id") roomId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getHeartList(roomId)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 49da1ffa..1e3b98ae 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -21,6 +21,8 @@ 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.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancel @@ -68,7 +70,6 @@ import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date -import java.util.Locale import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.write @@ -76,6 +77,8 @@ import kotlin.concurrent.write @Transactional(readOnly = true) class LiveRoomService( private val menuService: LiveRoomMenuService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, private val repository: LiveRoomRepository, private val rouletteRepository: NewRouletteRepository, @@ -114,6 +117,15 @@ class LiveRoomService( ) { private val tokenLocks: MutableMap = mutableMapOf() + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang).orEmpty() + return if (args.isNotEmpty()) { + String.format(template, *args) + } else { + template + } + } + @Transactional(readOnly = true) fun getRoomList( dateString: String?, @@ -169,13 +181,16 @@ class LiveRoomService( } } + val beginDateTimeFormat = messageSource + .getMessage("live.room.datetime_format", langContext.lang) + .orEmpty() val beginDateTime = it.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) .format( DateTimeFormatter - .ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분") - .withLocale(Locale.KOREAN) + .ofPattern(beginDateTimeFormat) + .withLocale(langContext.lang.locale) ) val beginDateTimeUtc = it.beginDateTime @@ -270,12 +285,12 @@ class LiveRoomService( @Transactional fun createLiveRoom(coverImage: MultipartFile?, requestString: String, member: Member): CreateLiveRoomResponse { if (repository.getActiveRoomIdList(memberId = member.id!!) >= 3) { - throw SodaException("예약 라이브는 최대 3개까지 가능합니다.") + throw SodaException(messageKey = "live.room.max_reservations") } val request = objectMapper.readValue(requestString, CreateLiveRoomRequest::class.java) if (request.coverImageUrl == null && coverImage == null) { - throw SodaException("커버이미지를 선택해 주세요.") + throw SodaException(messageKey = "live.room.cover_image_required") } val now = LocalDateTime.now() @@ -299,18 +314,18 @@ class LiveRoomService( request.beginDateTimeString != null && beginDateTime < now.plusMinutes(30) ) { - throw SodaException("현재시각 기준, 30분 이후부터 설정가능합니다.") + throw SodaException(messageKey = "live.room.start_time_minimum") } if ( request.type == LiveRoomType.PRIVATE && (request.password == null || request.password.length != 6) ) { - throw SodaException("방 입장 비밀번호 6자리를 입력해 주세요.") + throw SodaException(messageKey = "live.room.password_required") } if (request.price in 1..9) { - throw SodaException("유료라이브는 10캔부터 설정 가능 합니다.") + throw SodaException(messageKey = "live.room.paid_min_can") } val room = LiveRoom( @@ -392,15 +407,17 @@ class LiveRoomService( } } + val createdMessage = if (createdRoom.channelName != null) { + formatMessage("live.room.fcm.message.started", createdRoom.title) + } else { + formatMessage("live.room.fcm.message.reserved", createdRoom.title) + } + applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CREATE_LIVE, title = createdRoom.member!!.nickname, - message = if (createdRoom.channelName != null) { - "라이브를 시작했습니다. - ${createdRoom.title}" - } else { - "라이브를 예약했습니다. - ${createdRoom.title}" - }, + message = createdMessage, isAuth = createdRoom.isAdult, isAvailableJoinCreator = createdRoom.isAvailableJoinCreator, roomId = createdRoom.id, @@ -413,11 +430,7 @@ class LiveRoomService( FcmEvent( type = FcmEventType.CREATE_LIVE, title = createdRoom.member!!.nickname, - message = if (createdRoom.channelName != null) { - "라이브를 시작했습니다. - ${createdRoom.title}" - } else { - "라이브를 예약했습니다. - ${createdRoom.title}" - }, + message = createdMessage, isAuth = createdRoom.isAdult, isAvailableJoinCreator = createdRoom.isAvailableJoinCreator, roomId = createdRoom.id, @@ -431,16 +444,23 @@ class LiveRoomService( fun getRoomDetail(roomId: Long, member: Member, timezone: String): GetRoomDetailResponse { val room = repository.getLiveRoom(id = roomId) - ?: throw SodaException("이미 종료된 방입니다") + ?: throw SodaException(messageKey = "live.room.already_ended") if (room.isAdult && member.auth == null) { - throw SodaException("본인인증이 필요한 서비스 입니다.") + throw SodaException(messageKey = "live.room.adult_verification_required") } + val detailDateTimeFormat = messageSource + .getMessage("live.room.datetime_format_detail", langContext.lang) + .orEmpty() val beginDateTime = room.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) - .format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")) + .format( + DateTimeFormatter + .ofPattern(detailDateTimeFormat) + .withLocale(langContext.lang.locale) + ) val response = GetRoomDetailResponse( roomId = roomId, @@ -526,18 +546,27 @@ class LiveRoomService( @Transactional fun startLive(request: StartLiveRequest, member: Member) { val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") val nowDateTime = LocalDateTime.now() if (nowDateTime.plusMinutes(10).isBefore(room.beginDateTime)) { + val startAvailableDateFormat = messageSource + .getMessage("live.room.datetime_format", langContext.lang) + .orEmpty() val startAvailableDateTimeString = room.beginDateTime .minusMinutes(10) .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of("Asia/Seoul")) - .format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분").withLocale(Locale.KOREAN)) + .format( + DateTimeFormatter + .ofPattern(startAvailableDateFormat) + .withLocale(langContext.lang.locale) + ) - throw SodaException("$startAvailableDateTimeString 이후에 시작할 수 있습니다.") + throw SodaException( + message = formatMessage("live.room.start_available_after", startAvailableDateTimeString) + ) } val activeRooms = repository.getRoomActiveAndChannelNameIsNotNull(memberId = member.id!!) @@ -556,11 +585,12 @@ class LiveRoomService( room.beginDateTime = nowDateTime + val startedMessage = formatMessage("live.room.fcm.message.started_now", room.title) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.START_LIVE, title = room.member!!.nickname, - message = "라이브를 시작했습니다 - ${room.title}", + message = startedMessage, isAuth = room.isAdult, isAvailableJoinCreator = room.isAvailableJoinCreator, roomId = room.id, @@ -573,7 +603,7 @@ class LiveRoomService( FcmEvent( type = FcmEventType.START_LIVE, title = room.member!!.nickname, - message = "라이브를 시작했습니다 - ${room.title}", + message = startedMessage, isAuth = room.isAdult, isAvailableJoinCreator = room.isAvailableJoinCreator, roomId = room.id, @@ -586,10 +616,10 @@ class LiveRoomService( @Transactional fun cancelLive(request: CancelLiveRequest, member: Member) { val room = repository.getLiveRoomAndAccountId(request.roomId, member.id!!) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") if (request.reason.isBlank()) { - throw SodaException("취소사유를 입력해 주세요.") + throw SodaException(messageKey = "live.room.cancel_reason_required") } room.isActive = false @@ -613,7 +643,7 @@ class LiveRoomService( it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + charge.title = formatMessage("live.room.can_title", it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -627,7 +657,7 @@ class LiveRoomService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "환불" + payment.method = formatMessage("live.room.refund_method") charge.payment = payment chargeRepository.save(charge) @@ -638,11 +668,12 @@ class LiveRoomService( val pushTokenListMap = memberRepository.getPushTokenFromReservationList(request.roomId) reservationRepository.cancelReservation(roomId = room.id!!) + val cancelMessage = formatMessage("live.room.fcm.message.canceled", room.title) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CANCEL_LIVE, title = room.member!!.nickname, - message = "라이브 취소 : ${room.title}", + message = cancelMessage, recipientsMap = pushTokenListMap ) ) @@ -651,21 +682,29 @@ class LiveRoomService( @Transactional fun enterLive(request: EnterOrQuitLiveRoomRequest, member: Member) { val room = repository.getLiveRoom(id = request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") if ( room.member!!.id!! != member.id!! && room.type == LiveRoomType.PRIVATE && (request.password == null || request.password != room.password) ) { - throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.") + throw SodaException(messageKey = "live.room.password_mismatch") } val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = room.member!!.id!!) - if (isBlocked) throw SodaException("${room.member!!.nickname}님의 요청으로 라이브에 입장할 수 없습니다.") + if (isBlocked) { + throw SodaException( + message = formatMessage("live.room.enter_blocked_by_host", room.member!!.nickname) + ) + } val kickOutCount = kickOutService.getKickOutCount(roomId = room.id!!, userId = member.id!!) - if (kickOutCount >= 2) throw SodaException("${room.member!!.nickname}님의 요청으로 라이브에 참여할 수 없습니다.") + if (kickOutCount >= 2) { + throw SodaException( + message = formatMessage("live.room.participation_blocked_by_host", room.member!!.nickname) + ) + } val lock = getOrCreateLock(memberId = member.id!!) lock.write { @@ -675,7 +714,7 @@ class LiveRoomService( } if (roomInfo.speakerCount + roomInfo.listenerCount + roomInfo.managerCount >= room.numberOfPeople) { - throw SodaException("방이 가득찼습니다.") + throw SodaException(messageKey = "live.room.full") } if ( @@ -684,11 +723,13 @@ class LiveRoomService( canRepository.isExistPaidLiveRoom(memberId = member.id!!, roomId = request.roomId) == null ) { val findMember = memberRepository.findByIdOrNull(id = member.id!!) - ?: throw SodaException("로그인 정보를 확인해 주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") val totalCan = findMember.getChargeCan(request.container) + findMember.getRewardCan(request.container) if (totalCan < room.price) { - throw SodaException("${room.price - totalCan}캔이 부족합니다. 충전 후 이용해 주세요.") + throw SodaException( + message = formatMessage("live.room.insufficient_can", room.price - totalCan) + ) } canPaymentService.spendCan( @@ -717,18 +758,18 @@ class LiveRoomService( fun getRecentRoomInfo(member: Member): GetRecentRoomInfoResponse { return repository.getRecentRoomInfo(memberId = member.id!!) - ?: throw SodaException("최근 데이터가 없습니다.") + ?: throw SodaException(messageKey = "live.room.recent_not_found") } @Transactional fun editLiveRoomInfo(roomId: Long, coverImage: MultipartFile?, requestString: String?, member: Member) { val room = repository.getLiveRoom(roomId) if (member.id == null || room?.member?.id != member.id!!) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } if (coverImage == null && requestString == null) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "live.room.no_changes") } if (coverImage != null) { @@ -808,10 +849,10 @@ class LiveRoomService( fun getRoomInfo(roomId: Long, member: Member): GetRoomInfoResponse { val roomInfo = roomInfoRepository.findByIdOrNull(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val room = repository.findByIdOrNull(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val currentTimeStamp = Date().time val expireTimestamp = (currentTimeStamp + (60 * 60 * 24 * 1000)) / 1000 @@ -905,10 +946,10 @@ class LiveRoomService( fun getDonationMessageList(roomId: Long, member: Member): List { val liveRoomCreatorId = repository.getLiveRoomCreatorId(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val roomInfo = roomInfoRepository.findByIdOrNull(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") return if (liveRoomCreatorId != member.id!!) { roomInfo.donationMessageList @@ -922,14 +963,14 @@ class LiveRoomService( val lock = getOrCreateLock(memberId = member.id!!) lock.write { val room = repository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") if (member.id!! != room.member!!.id!!) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") roomInfo.removeDonationMessage(request.messageUUID) roomInfoRepository.save(roomInfo) @@ -938,12 +979,12 @@ class LiveRoomService( fun getUserProfile(roomId: Long, userId: Long, member: Member): GetLiveRoomUserProfileResponse { val room = repository.getLiveRoom(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val roomInfo = roomInfoRepository.findByIdOrNull(roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val user = memberRepository.findByIdOrNull(userId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val isFollowing = if (user.role == MemberRole.CREATOR) { explorerQueryRepository @@ -981,7 +1022,11 @@ class LiveRoomService( } else { "$cloudFrontHost/profile/default-profile.png" }, - gender = if (user.gender == Gender.FEMALE) "여" else if (user.gender == Gender.MALE) "남" else "미", + gender = when (user.gender) { + Gender.FEMALE -> messageSource.getMessage("member.gender.female", langContext.lang) + Gender.MALE -> messageSource.getMessage("member.gender.male", langContext.lang) + else -> messageSource.getMessage("member.gender.unknown", langContext.lang) + }.orEmpty(), instagramUrl = user.instagramUrl, youtubeUrl = user.youtubeUrl, websiteUrl = user.websiteUrl, @@ -1009,13 +1054,13 @@ class LiveRoomService( val lock = getOrCreateLock(memberId = request.memberId) lock.write { val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val member = memberRepository.findByIdOrNull(request.memberId) - ?: throw SodaException("로그인 정보를 확인해 주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (roomInfo.speakerCount > 5) { - throw SodaException("스피커 정원이 초과하였습니다.") + throw SodaException(messageKey = "live.room.speaker_limit_exceeded") } roomInfo.removeListener(member) @@ -1030,10 +1075,10 @@ class LiveRoomService( val lock = getOrCreateLock(memberId = request.memberId) lock.write { val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val member = memberRepository.findByIdOrNull(request.memberId) - ?: throw SodaException("로그인 정보를 확인해 주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") roomInfo.removeSpeaker(member) roomInfo.removeManager(member) @@ -1046,25 +1091,27 @@ class LiveRoomService( fun setManager(request: SetManagerOrSpeakerOrAudienceRequest, member: Member) { val lock = getOrCreateLock(memberId = member.id!!) lock.write { - val room = repository.getLiveRoom(request.roomId) ?: throw SodaException("잘못된 요청입니다.") + val room = repository.getLiveRoom(request.roomId) + ?: throw SodaException(messageKey = "common.error.invalid_request") if (room.member!!.id!! != member.id!!) { - throw SodaException("권한이 없습니다.") + throw SodaException(messageKey = "common.error.access_denied") } - val user = memberRepository.findByIdOrNull(request.memberId) ?: throw SodaException("해당하는 유저가 없습니다.") + val user = memberRepository.findByIdOrNull(request.memberId) + ?: throw SodaException(messageKey = "live.room.user_not_found") val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") val roomAccountResponse = LiveRoomMember(member = user, cloudFrontHost) if (roomInfo.managerList.contains(roomAccountResponse)) { - throw SodaException("이미 매니저 입니다.") + throw SodaException(messageKey = "live.room.already_manager") } if ( !roomInfo.speakerList.contains(roomAccountResponse) && !roomInfo.listenerList.contains(roomAccountResponse) ) { - throw SodaException("해당하는 유저가 없습니다.") + throw SodaException(messageKey = "live.room.user_not_found") } roomInfo.removeListener(user) @@ -1078,12 +1125,12 @@ class LiveRoomService( @Transactional fun donation(request: LiveRoomDonationRequest, member: Member): String? { val room = repository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") - val host = room.member ?: throw SodaException("잘못된 요청입니다.") + val host = room.member ?: throw SodaException(messageKey = "common.error.invalid_request") if (host.role != MemberRole.CREATOR) { - throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.") + throw SodaException(messageKey = "live.room.creator_contract_only_donation") } canPaymentService.spendCan( @@ -1099,7 +1146,7 @@ class LiveRoomService( val lock = getOrCreateLock(memberId = member.id!!) lock.write { val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") roomInfo.addDonationMessage( memberId = member.id!!, @@ -1124,12 +1171,12 @@ class LiveRoomService( @Transactional fun donationV2(request: LiveRoomDonationRequest, member: Member): LiveRoomDonationResponse? { val room = repository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") - val host = room.member ?: throw SodaException("잘못된 요청입니다.") + val host = room.member ?: throw SodaException(messageKey = "common.error.invalid_request") if (host.role != MemberRole.CREATOR) { - throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.") + throw SodaException(messageKey = "live.room.creator_contract_only_donation") } canPaymentService.spendCan( @@ -1145,7 +1192,7 @@ class LiveRoomService( val lock = getOrCreateLock(memberId = member.id!!) lock.write { val roomInfo = roomInfoRepository.findByIdOrNull(room.id!!) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") roomInfo.addDonationMessage( memberId = member.id!!, @@ -1170,20 +1217,20 @@ class LiveRoomService( @Transactional fun refundDonation(roomId: Long, member: Member) { val donator = memberRepository.findByIdOrNull(member.id) - ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ?: throw SodaException(messageKey = "live.room.donation_refund_failed") val useCan = canRepository.getCanUsedForLiveRoomNotRefund( memberId = member.id!!, roomId = roomId, canUsage = CanUsage.DONATION - ) ?: throw SodaException("후원에 실패한 캔이 환불되지 않았습니다\n고객센터로 문의해주세요.") + ) ?: throw SodaException(messageKey = "live.room.donation_refund_failed") useCan.isRefund = true val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) useCanCalculates.forEach { it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + charge.title = formatMessage("live.room.can_title", it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -1197,7 +1244,7 @@ class LiveRoomService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "환불" + payment.method = formatMessage("live.room.refund_method") charge.payment = payment chargeRepository.save(charge) @@ -1205,7 +1252,7 @@ class LiveRoomService( } fun getDonationStatus(roomId: Long, memberId: Long): GetLiveRoomDonationStatusResponse { - val room = repository.getLiveRoom(roomId) ?: throw SodaException("잘못된 요청입니다.") + val room = repository.getLiveRoom(roomId) ?: throw SodaException(messageKey = "common.error.invalid_request") val isLiveCreator = room.member!!.id == memberId val donationList = repository.getDonationList( @@ -1267,12 +1314,12 @@ class LiveRoomService( @Transactional fun likeHeart(request: LiveRoomLikeHeartRequest, member: Member) { val room = repository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") - val host = room.member ?: throw SodaException("잘못된 요청입니다.") + val host = room.member ?: throw SodaException(messageKey = "common.error.invalid_request") if (host.role != MemberRole.CREATOR) { - throw SodaException("주식회사 소다라이브와 계약한\n크리에이터에게만 후원을 하실 수 있습니다.") + throw SodaException(messageKey = "live.room.creator_contract_only_donation") } canPaymentService.spendCan( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt index fa779732..f04834b4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutController.kt @@ -18,7 +18,7 @@ class LiveRoomKickOutController(private val service: LiveRoomKickOutService) { @RequestBody request: LiveRoomKickOutRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.kickOut(request = request, member = member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt index b193a824..b900e53b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/kickout/LiveRoomKickOutService.kt @@ -22,17 +22,17 @@ class LiveRoomKickOutService( ) { fun kickOut(request: LiveRoomKickOutRequest, member: Member) { val room = roomRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브가 없습니다.") + ?: throw SodaException(messageKey = "live.room.not_found") val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) - ?: throw SodaException("해당하는 라이브의 정보가 없습니다.") + ?: throw SodaException(messageKey = "live.room.info_not_found") if (room.member == null || room.member!!.id == null) { - throw SodaException("해당하는 라이브가 없습니다.") + throw SodaException(messageKey = "live.room.not_found") } if (!roomInfo.managerList.contains(LiveRoomMember(member, cloudFrontHost)) && room.member!!.id != member.id) { - throw SodaException("권한이 없습니다.") + throw SodaException(messageKey = "common.error.access_denied") } var liveRoomKickOut = repository.findByIdOrNull(request.roomId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuController.kt index 7c307b64..ee558bf3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuController.kt @@ -21,7 +21,7 @@ class LiveRoomMenuController(private val service: LiveRoomMenuService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getAllLiveMenu(creatorId = creatorId, memberId = member.id!!)) } @@ -31,7 +31,7 @@ class LiveRoomMenuController(private val service: LiveRoomMenuService) { @RequestBody request: UpdateLiveMenuRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( if (request.id > 0) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuService.kt index e9fc6ab5..0e17a15b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/menu/LiveRoomMenuService.kt @@ -10,7 +10,7 @@ class LiveRoomMenuService( private val repository: LiveRoomMenuRepository ) { fun getAllLiveMenu(creatorId: Long, memberId: Long): List { - if (creatorId != memberId) throw SodaException("잘못된 요청입니다.") + if (creatorId != memberId) throw SodaException(messageKey = "common.error.invalid_request") return repository.findByCreatorId(creatorId) .sortedBy { it.id } @@ -25,7 +25,7 @@ class LiveRoomMenuService( val menuList = repository.findByCreatorId(creatorId = memberId) if (menuList.size >= 3) { - throw SodaException("메뉴판의 최대개수는 3개입니다.") + throw SodaException(messageKey = "live.room.menu.max_count") } if (request.isActive) { @@ -51,7 +51,7 @@ class LiveRoomMenuService( val menuList = repository.findByCreatorId(creatorId = memberId) if (menuList.isEmpty()) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } menuList.forEach { @@ -95,7 +95,7 @@ class LiveRoomMenuService( private fun liveMenuValidate(menu: String) { if (menu.isBlank()) { - throw SodaException("메뉴판은 빈칸일 수 없습니다.") + throw SodaException(messageKey = "live.room.menu.blank_not_allowed") } } -- 2.49.1 From f429ffbbbefe177e123ddeec518ccc4904cd09a8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 16:19:04 +0900 Subject: [PATCH 76/90] =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B6=84=EB=A6=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/event/EventService.kt | 14 ++-- .../co/vividnext/sodalive/faq/FaqService.kt | 23 +++--- .../sodalive/i18n/SodaMessageSource.kt | 72 +++++++++++++++++++ .../vividnext/sodalive/jwt/TokenProvider.kt | 8 ++- .../live/recommend/LiveRecommendController.kt | 4 +- .../reservation/LiveReservationController.kt | 8 +-- .../reservation/LiveReservationService.kt | 51 ++++++++----- 7 files changed, 138 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt index 1a99eff6..b103cd2a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt @@ -78,7 +78,9 @@ class EventService( startDateString: String, endDateString: String ): Long { - if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요") + if (detail == null && link.isNullOrBlank()) { + throw SodaException(messageKey = "event.detail_or_link_required") + } val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val startDate = LocalDate.parse(startDateString, dateTimeFormatter).atTime(0, 0) @@ -146,7 +148,7 @@ class EventService( event.detailImage = detailImagePath event.popupImage = popupImagePath - return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.") + return event.id ?: throw SodaException(messageKey = "event.save_failed") } @Transactional @@ -162,10 +164,10 @@ class EventService( startDateString: String? = null, endDateString: String? = null ) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val event = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (thumbnail != null) { val metadata = ObjectMetadata() @@ -234,9 +236,9 @@ class EventService( @Transactional fun delete(id: Long) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val event = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") event.isActive = false } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt index 5912d4a9..3e418f34 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/faq/FaqService.kt @@ -12,12 +12,12 @@ class FaqService( ) { @Transactional fun save(request: CreateFaqRequest): Long { - if (request.question.isBlank()) throw SodaException("질문을 입력하세요.") - if (request.answer.isBlank()) throw SodaException("답변을 입력하세요.") - if (request.category.isBlank()) throw SodaException("카테고리를 선택하세요.") + if (request.question.isBlank()) throw SodaException(messageKey = "faq.question_required") + if (request.answer.isBlank()) throw SodaException(messageKey = "faq.answer_required") + if (request.category.isBlank()) throw SodaException(messageKey = "faq.category_required") val category = queryRepository.getCategory(request.category) - ?: throw SodaException("잘못된 카테고리 입니다.") + ?: throw SodaException(messageKey = "faq.invalid_category") val faq = Faq(request.question, request.answer) faq.category = category @@ -28,30 +28,31 @@ class FaqService( @Transactional fun modify(request: ModifyFaqRequest) { val faq = queryRepository.getFaq(request.id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (request.question != null) { - if (request.question.isBlank()) throw SodaException("질문을 입력하세요.") + if (request.question.isBlank()) throw SodaException(messageKey = "faq.question_required") faq.question = request.question } if (request.answer != null) { - if (request.answer.isBlank()) throw SodaException("답변을 입력하세요.") + if (request.answer.isBlank()) throw SodaException(messageKey = "faq.answer_required") faq.answer = request.answer } if (request.category != null) { - if (request.category.isBlank()) throw SodaException("카테고리를 선택하세요.") - val category = queryRepository.getCategory(request.category) ?: throw SodaException("잘못된 카테고리 입니다.") + if (request.category.isBlank()) throw SodaException(messageKey = "faq.category_required") + val category = queryRepository.getCategory(request.category) + ?: throw SodaException(messageKey = "faq.invalid_category") faq.category = category } } @Transactional fun delete(id: Long) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val faq = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") faq.isActive = false } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 8e7d95dd..bffaaf79 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -626,6 +626,75 @@ class SodaMessageSource { ) ) + private val eventMessages = mapOf( + "event.detail_or_link_required" to mapOf( + Lang.KO to "상세이미지 혹은 링크를 등록하세요", + Lang.EN to "Please register a detail image or link.", + Lang.JA to "詳細画像またはリンクを登録してください。" + ), + "event.save_failed" to mapOf( + Lang.KO to "이벤트 등록을 하지 못했습니다.", + Lang.EN to "Failed to register the event.", + Lang.JA to "イベントの登録に失敗しました。" + ) + ) + + private val faqMessages = mapOf( + "faq.question_required" to mapOf( + Lang.KO to "질문을 입력하세요.", + Lang.EN to "Please enter a question.", + Lang.JA to "質問を入力してください。" + ), + "faq.answer_required" to mapOf( + Lang.KO to "답변을 입력하세요.", + Lang.EN to "Please enter an answer.", + Lang.JA to "回答を入力してください。" + ), + "faq.category_required" to mapOf( + Lang.KO to "카테고리를 선택하세요.", + Lang.EN to "Please select a category.", + Lang.JA to "カテゴリーを選択してください。" + ), + "faq.invalid_category" to mapOf( + Lang.KO to "잘못된 카테고리 입니다.", + Lang.EN to "Invalid category.", + Lang.JA to "不正なカテゴリーです。" + ) + ) + + private val liveReservationMessages = mapOf( + "live.reservation.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease try again.", + Lang.JA to "不正なリクエストです。\nもう一度やり直してください。" + ), + "live.reservation.already_reserved" to mapOf( + Lang.KO to "이미 예약한 라이브 입니다.", + Lang.EN to "You have already reserved this live.", + Lang.JA to "すでに予約済みのライブです。" + ), + "live.reservation.invalid_reservation" to mapOf( + Lang.KO to "잘못된 예약정보 입니다.", + Lang.EN to "Invalid reservation information.", + Lang.JA to "不正な予約情報です。" + ), + "live.reservation.cancel_not_allowed_within_4_hours" to mapOf( + Lang.KO to "라이브 시작 4시간 이내에는 예약취소가 불가능 합니다.", + Lang.EN to "Reservations cannot be canceled within 4 hours of the live start.", + Lang.JA to "ライブ開始4時間以内は予約をキャンセルできません。" + ), + "live.reservation.price_free" to mapOf( + Lang.KO to "무료", + Lang.EN to "Free", + Lang.JA to "無料" + ), + "live.reservation.datetime_format" to mapOf( + Lang.KO to "yyyy년 M월 d일 (E), a hh:mm", + Lang.EN to "yyyy MMM d (EEE), h:mm a", + Lang.JA to "yyyy年 M月 d日 (E) a hh:mm" + ) + ) + private val liveRouletteMessages = mapOf( "live.roulette.unavailable" to mapOf( Lang.KO to "룰렛을 사용할 수 없습니다.", @@ -949,7 +1018,10 @@ class SodaMessageSource { memberMessages, memberValidationMessages, memberSocialMessages, + eventMessages, + faqMessages, liveRouletteMessages, + liveReservationMessages, liveTagMessages, liveRoomMessages, liveRoomMenuMessages, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt index 0ec16ac6..f851665a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt @@ -85,12 +85,14 @@ class TokenProvider( val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) } val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong()) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") - if (!memberToken.tokenSet.contains(token)) throw SodaException("로그인 정보를 확인해주세요.") + if (!memberToken.tokenSet.contains(token)) { + throw SodaException(messageKey = "common.error.bad_credentials") + } val member = repository.findByIdOrNull(id = claims.subject.toLong()) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") val principal = MemberAdapter(member) return UsernamePasswordAuthenticationToken(principal, token, authorities) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt index 3b8a2521..d1b7280a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/recommend/LiveRecommendController.kt @@ -30,7 +30,7 @@ class LiveRecommendController(private val service: LiveRecommendService) { fun getFollowingChannelList( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getFollowingChannelList(member)) } @@ -40,7 +40,7 @@ class LiveRecommendController(private val service: LiveRecommendService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getFollowingAllChannelList(member, pageable)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt index ead7b722..45507782 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationController.kt @@ -21,7 +21,7 @@ class LiveReservationController(private val service: LiveReservationService) { @RequestBody request: MakeLiveReservationRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.makeReservation(request, member.id!!)) } @@ -32,7 +32,7 @@ class LiveReservationController(private val service: LiveReservationService) { @RequestParam(value = "timezone") timezone: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getReservationList(member.id!!, isActive, timezone)) } @@ -42,7 +42,7 @@ class LiveReservationController(private val service: LiveReservationService) { @RequestParam(value = "timezone") timezone: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getReservation(id, member.id!!, timezone)) } @@ -51,7 +51,7 @@ class LiveReservationController(private val service: LiveReservationService) { @RequestBody request: CancelLiveReservationRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.cancelReservation(request, member.id!!)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt index df2a75df..2e80924c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.live.reservation import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.member.MemberRepository @@ -21,32 +23,36 @@ class LiveReservationService( private val memberRepository: MemberRepository, private val canPaymentService: CanPaymentService, private val liveReservationCancelRepository: LiveReservationCancelRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse { val room = liveRoomRepository.findByIdOrNull(id = request.roomId) - ?: throw SodaException(message = "잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "live.reservation.invalid_request_retry") val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException(message = "로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if ( room.member!!.id!! != memberId && room.type == LiveRoomType.PRIVATE && (request.password == null || request.password != room.password) ) { - throw SodaException("비밀번호가 일치하지 않습니다.\n다시 확인 후 입력해주세요.") + throw SodaException(messageKey = "live.room.password_mismatch") } if (repository.isExistsReservation(roomId = request.roomId, memberId = memberId)) { - throw SodaException("이미 예약한 라이브 입니다.") + throw SodaException(messageKey = "live.reservation.already_reserved") } val haveCan = member.getChargeCan(request.container) + member.getRewardCan(request.container) if (haveCan < room.price) { - throw SodaException("${room.price - haveCan}캔이 부족합니다. 충전 후 이용해 주세요.") + val messageTemplate = messageSource.getMessage("live.room.insufficient_can", langContext.lang).orEmpty() + val message = String.format(messageTemplate, room.price - haveCan) + throw SodaException(message = message) } if (room.price > 0) { @@ -67,16 +73,21 @@ class LiveReservationService( val beginDateTime = room.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(request.timezone)) + val reservationDateFormat = messageSource.getMessage( + "live.reservation.datetime_format", + langContext.lang + ).orEmpty() return MakeLiveReservationResponse( reservationId = reservation.id!!, nickname = room.member!!.nickname, title = room.title, - beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern("yyyy년 M월 d일 (E), a hh:mm")), + beginDateString = beginDateTime.format(DateTimeFormatter.ofPattern(reservationDateFormat)), price = if (room.price > 0) { - "${room.price} 캔" + val priceTemplate = messageSource.getMessage("live.room.can_title", langContext.lang).orEmpty() + String.format(priceTemplate, room.price) } else { - "무료" + messageSource.getMessage("live.reservation.price_free", langContext.lang).orEmpty() }, haveCan = haveCan, useCan = room.price, @@ -85,6 +96,10 @@ class LiveReservationService( } fun getReservationList(memberId: Long, active: Boolean, timezone: String): List { + val detailDateFormat = messageSource.getMessage( + "live.room.datetime_format_detail", + langContext.lang + ).orEmpty() return repository .getReservationListByMemberId(memberId, active) .asSequence() @@ -105,7 +120,7 @@ class LiveReservationService( price = it.room!!.price, masterNickname = it.room!!.member!!.nickname, beginDateTime = beginDateTime.format( - DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + DateTimeFormatter.ofPattern(detailDateFormat) ), cancelable = beginDateTime.minusHours(4).isAfter( LocalDateTime.now() @@ -119,8 +134,12 @@ class LiveReservationService( fun getReservation(reservationId: Long, memberId: Long, timezone: String): GetLiveReservationResponse { val reservation = repository.getReservationByReservationAndMemberId(reservationId, memberId) - ?: throw SodaException("잘못된 예약정보 입니다.") + ?: throw SodaException(messageKey = "live.reservation.invalid_reservation") + val detailDateFormat = messageSource.getMessage( + "live.room.datetime_format_detail", + langContext.lang + ).orEmpty() val beginDateTime = reservation.room!!.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) @@ -137,7 +156,7 @@ class LiveReservationService( price = reservation.room!!.price, masterNickname = reservation.room!!.member!!.nickname, beginDateTime = beginDateTime.format( - DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a") + DateTimeFormatter.ofPattern(detailDateFormat) ), cancelable = beginDateTime.minusHours(4).isAfter( LocalDateTime.now() @@ -150,22 +169,22 @@ class LiveReservationService( @Transactional fun cancelReservation(request: CancelLiveReservationRequest, memberId: Long) { if (request.reason.isBlank()) { - throw SodaException("취소사유를 입력하세요.") + throw SodaException(messageKey = "live.room.cancel_reason_required") } val reservation = repository.findByIdOrNull(request.reservationId) - ?: throw SodaException("잘못된 예약정보 입니다.") + ?: throw SodaException(messageKey = "live.reservation.invalid_reservation") if (reservation.member == null || reservation.member!!.id!! != memberId) { - throw SodaException("잘못된 예약정보 입니다.") + throw SodaException(messageKey = "live.reservation.invalid_reservation") } if (reservation.room == null || reservation.room?.id == null) { - throw SodaException("잘못된 예약정보 입니다.") + throw SodaException(messageKey = "live.reservation.invalid_reservation") } if (reservation.room!!.beginDateTime.isBefore(LocalDateTime.now().plusHours(4))) { - throw SodaException("라이브 시작 4시간 이내에는 예약취소가 불가능 합니다.") + throw SodaException(messageKey = "live.reservation.cancel_not_allowed_within_4_hours") } if (reservation.room!!.price > 0) { -- 2.49.1 From 4087d114200d837ec2ac8cb301b3b43a6d815dcc Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 16:44:27 +0900 Subject: [PATCH 77/90] =?UTF-8?q?=ED=83=90=EC=83=89=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/explorer/ExplorerController.kt | 37 ++-- .../explorer/ExplorerQueryRepository.kt | 30 +++- .../sodalive/explorer/ExplorerService.kt | 70 +++++--- .../CreatorCommunityController.kt | 22 +-- .../CreatorCommunityService.kt | 38 +++-- .../sodalive/i18n/SodaMessageSource.kt | 161 +++++++++++++++++- 6 files changed, 290 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt index b47792a5..21a4c8e1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt @@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.explorer.profile.PostCreatorNoticeRequest import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize @@ -20,12 +22,16 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/explorer") -class ExplorerController(private val service: ExplorerService) { +class ExplorerController( + private val service: ExplorerService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @GetMapping("/creator-rank") fun getCreatorRank( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCreatorRank(memberId = member.id!!)) } @@ -34,7 +40,7 @@ class ExplorerController(private val service: ExplorerService) { fun getExplorer( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getExplorer(member)) } @@ -44,7 +50,7 @@ class ExplorerController(private val service: ExplorerService) { @RequestParam channel: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getSearchChannel(channel, member)) } @@ -55,7 +61,7 @@ class ExplorerController(private val service: ExplorerService) { @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCreatorProfile( creatorId = creatorId, @@ -72,7 +78,7 @@ class ExplorerController(private val service: ExplorerService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCreatorProfileDonationRanking(creatorId, pageable, member)) } @@ -81,8 +87,9 @@ class ExplorerController(private val service: ExplorerService) { @RequestBody request: PostWriteCheersRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok(service.writeCheers(request, member), "등록되었습니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val message = messageSource.getMessage("explorer.cheers.created", langContext.lang) + ApiResponse.ok(service.writeCheers(request, member), message) } @PutMapping("/profile/cheers") @@ -90,8 +97,9 @@ class ExplorerController(private val service: ExplorerService) { @RequestBody request: PutWriteCheersRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok(service.modifyCheers(request, member), "수정되었습니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val message = messageSource.getMessage("explorer.cheers.updated", langContext.lang) + ApiResponse.ok(service.modifyCheers(request, member), message) } @PostMapping("/profile/notice") @@ -100,8 +108,9 @@ class ExplorerController(private val service: ExplorerService) { @RequestBody request: PostCreatorNoticeRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok(service.saveNotice(member, request.notice), "공지사항이 저장되었습니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val message = messageSource.getMessage("explorer.notice.saved", langContext.lang) + ApiResponse.ok(service.saveNotice(member, request.notice), message) } @GetMapping("/profile/{id}/follower-list") @@ -110,7 +119,7 @@ class ExplorerController(private val service: ExplorerService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getFollowerList(creatorId, member, pageable)) } @@ -121,7 +130,7 @@ class ExplorerController(private val service: ExplorerService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCreatorProfileCheers(creatorId, timezone, pageable)) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index dfce6763..9f8c586d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -19,6 +19,9 @@ import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.QChannelNotice.channelNotice import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers import kr.co.vividnext.sodalive.explorer.profile.TimeDifferenceResult +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoomType import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom @@ -40,11 +43,12 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.Locale @Repository class ExplorerQueryRepository( private val queryFactory: JPAQueryFactory, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -243,7 +247,7 @@ class ExplorerQueryRepository( val creator = queryFactory .selectFrom(member) .where(member.id.eq(creatorId)) - .fetchFirst() ?: throw SodaException("없는 사용자 입니다.") + .fetchFirst() ?: throw SodaException(messageKey = "member.validation.user_not_found") val creatorTagCount = creator.tags .asSequence() @@ -383,6 +387,12 @@ class ExplorerQueryRepository( ) } + val dateTimePattern = messageSource + .getMessage("explorer.date.live_room.datetime_format", langContext.lang) + ?: messageSource.getMessage("explorer.date.live_room.datetime_format", Lang.KO).orEmpty() + val dateTimeFormatter = DateTimeFormatter.ofPattern(dateTimePattern) + .withLocale(langContext.lang.locale) + return result .map { val reservations = it.reservations @@ -393,11 +403,7 @@ class ExplorerQueryRepository( val beginDateTime = it.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) - .format( - DateTimeFormatter - .ofPattern("yyyy년 MM월 dd일 (E) a hh시 mm분") - .withLocale(Locale.KOREAN) - ) + .format(dateTimeFormatter) val beginDateTimeUtc = it.beginDateTime .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) @@ -451,6 +457,12 @@ class ExplorerQueryRepository( } fun getCheersList(creatorId: Long, timezone: String, offset: Long, limit: Long): GetCheersResponse { + val cheersDatePattern = messageSource + .getMessage("explorer.date.cheers.format", langContext.lang) + ?: messageSource.getMessage("explorer.date.cheers.format", Lang.KO).orEmpty() + val cheersDateFormatter = DateTimeFormatter.ofPattern(cheersDatePattern) + .withLocale(langContext.lang.locale) + val totalCount = queryFactory .selectFrom(creatorCheers) .where( @@ -489,7 +501,7 @@ class ExplorerQueryRepository( }, content = it.cheers, languageCode = it.languageCode, - date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + date = date.format(cheersDateFormatter), replyList = it.children.asSequence() .map { cheers -> val replyDate = cheers.createdAt!! @@ -507,7 +519,7 @@ class ExplorerQueryRepository( }, content = cheers.cheers, languageCode = cheers.languageCode, - date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + date = replyDate.format(cheersDateFormatter), replyList = listOf() ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index c438b835..9a81fcd9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -19,7 +19,9 @@ import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole @@ -50,6 +52,7 @@ class ExplorerService( private val applicationEventPublisher: ApplicationEventPublisher, private val contentTranslationRepository: ContentTranslationRepository, + private val messageSource: SodaMessageSource, private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") @@ -68,15 +71,20 @@ class ExplorerService( val lastSunday = lastMonday .plusDays(6) - val startDateFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - val endDateFormatter = DateTimeFormatter.ofPattern("MM월 dd일") + val startDatePattern = messageSource.getMessage("explorer.date.creator_rank.start_format", langContext.lang) + ?: messageSource.getMessage("explorer.date.creator_rank.start_format", Lang.KO).orEmpty() + val endDatePattern = messageSource.getMessage("explorer.date.creator_rank.end_format", langContext.lang) + ?: messageSource.getMessage("explorer.date.creator_rank.end_format", Lang.KO).orEmpty() + val startDateFormatter = DateTimeFormatter.ofPattern(startDatePattern).withLocale(langContext.lang.locale) + val endDateFormatter = DateTimeFormatter.ofPattern(endDatePattern).withLocale(langContext.lang.locale) val formattedLastMonday = lastMonday.format(startDateFormatter) val formattedLastSunday = lastSunday.format(endDateFormatter) return GetExplorerSectionResponse( - title = "인기 크리에이터", - coloredTitle = "인기", + title = messageSource.getMessage("explorer.section.creator_rank.title", langContext.lang).orEmpty(), + coloredTitle = messageSource.getMessage("explorer.section.creator_rank.colored_title", langContext.lang) + .orEmpty(), color = "FF5C49", desc = "$formattedLastMonday ~ $formattedLastSunday", creators = creatorRankings @@ -99,15 +107,20 @@ class ExplorerService( val lastSunday = lastMonday .plusDays(6) - val startDateFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - val endDateFormatter = DateTimeFormatter.ofPattern("MM월 dd일") + val startDatePattern = messageSource.getMessage("explorer.date.creator_rank.start_format", langContext.lang) + ?: messageSource.getMessage("explorer.date.creator_rank.start_format", Lang.KO).orEmpty() + val endDatePattern = messageSource.getMessage("explorer.date.creator_rank.end_format", langContext.lang) + ?: messageSource.getMessage("explorer.date.creator_rank.end_format", Lang.KO).orEmpty() + val startDateFormatter = DateTimeFormatter.ofPattern(startDatePattern).withLocale(langContext.lang.locale) + val endDateFormatter = DateTimeFormatter.ofPattern(endDatePattern).withLocale(langContext.lang.locale) val formattedLastMonday = lastMonday.format(startDateFormatter) val formattedLastSunday = lastSunday.format(endDateFormatter) val creatorRankingSection = GetExplorerSectionResponse( - title = "인기 크리에이터", - coloredTitle = "인기", + title = messageSource.getMessage("explorer.section.creator_rank.title", langContext.lang).orEmpty(), + coloredTitle = messageSource.getMessage("explorer.section.creator_rank.colored_title", langContext.lang) + .orEmpty(), color = "FF5C49", desc = "$formattedLastMonday ~ $formattedLastSunday", creators = creatorRankings @@ -121,16 +134,18 @@ class ExplorerService( .map { it.toExplorerSectionCreator(cloudFrontHost) } val newCreatorsSection = GetExplorerSectionResponse( - title = "새로 시작", - coloredTitle = "새로", + title = messageSource.getMessage("explorer.section.new_creators.title", langContext.lang).orEmpty(), + coloredTitle = messageSource.getMessage("explorer.section.new_creators.colored_title", langContext.lang) + .orEmpty(), color = "5FD28F", creators = newCreators ) sections.add(newCreatorsSection) val maleCreatorSection = GetExplorerSectionResponse( - title = "남자 크리에이터", - coloredTitle = "남자", + title = messageSource.getMessage("explorer.section.male_creators.title", langContext.lang).orEmpty(), + coloredTitle = messageSource.getMessage("explorer.section.male_creators.colored_title", langContext.lang) + .orEmpty(), color = "39abde", creators = queryRepository .findCreatorByGender(1) @@ -139,8 +154,9 @@ class ExplorerService( ) val femaleCreatorSection = GetExplorerSectionResponse( - title = "여자 크리에이터", - coloredTitle = "여자", + title = messageSource.getMessage("explorer.section.female_creators.title", langContext.lang).orEmpty(), + coloredTitle = messageSource.getMessage("explorer.section.female_creators.colored_title", langContext.lang) + .orEmpty(), color = "ffa517", creators = queryRepository .findCreatorByGender(0) @@ -162,7 +178,7 @@ class ExplorerService( fun getSearchChannel(channel: String, member: Member): List { if (channel.length < 2) { - throw SodaException("두 글자 이상 입력 하셔야 합니다.") + throw SodaException(messageKey = "explorer.search.channel.min_length") } return queryRepository.getSearchChannel(channel, member.id!!) @@ -179,11 +195,17 @@ class ExplorerService( member: Member ): GetCreatorProfileResponse { // 크리에이터(유저) 정보 - val creatorAccount = queryRepository.getMember(creatorId) ?: throw SodaException("없는 사용자 입니다.") + val creatorAccount = queryRepository.getMember(creatorId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") // 차단된 사용자 체크 val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) - if (isBlocked) throw SodaException("${creatorAccount.nickname}님의 요청으로 채널 접근이 제한됩니다.") + if (isBlocked) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creatorAccount.nickname)) + } val isCreator = creatorAccount.role == MemberRole.CREATOR @@ -461,10 +483,16 @@ class ExplorerService( @Transactional fun writeCheers(request: PostWriteCheersRequest, member: Member) { - val creator = queryRepository.getMember(request.creatorId) ?: throw SodaException("없는 사용자 입니다.") + val creator = queryRepository.getMember(request.creatorId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId) - if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.") + if (isBlocked) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_cheers", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creator.nickname)) + } val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode) cheers.member = member @@ -510,7 +538,7 @@ class ExplorerService( @Transactional fun modifyCheers(request: PutWriteCheersRequest, member: Member) { val cheers = queryRepository.getCheers(request.cheersId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (cheers.member!!.id!! == member.id!!) { if (request.content != null) { @@ -544,7 +572,7 @@ class ExplorerService( FcmEvent( type = FcmEventType.CHANGE_NOTICE, title = member.nickname, - message = "새 글이 등록되었습니다.", + message = messageSource.getMessage("explorer.notice.fcm.message", langContext.lang).orEmpty(), creatorId = member.id!! ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt index c84e587f..b85e3cf3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityController.kt @@ -36,7 +36,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.createCommunityPost( @@ -57,7 +57,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.modifyCommunityPost( @@ -75,7 +75,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCommunityPostList( @@ -95,7 +95,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestParam timezone: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCommunityPostDetail( @@ -112,7 +112,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestBody request: PostCommunityPostLikeRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.communityPostLike(request, member)) } @@ -122,7 +122,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestBody request: CreateCommunityPostCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.createCommunityPostComment( @@ -140,7 +140,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestBody request: ModifyCommunityPostCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.modifyCommunityPostComment(request = request, member = member) @@ -154,7 +154,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCommunityPostCommentList( @@ -174,7 +174,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCommentReplyList( @@ -191,7 +191,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestParam timezone: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getLatestPostListFromCreatorsYouFollow( @@ -207,7 +207,7 @@ class CreatorCommunityController(private val service: CreatorCommunityService) { @RequestBody request: PurchasePostRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.purchasePost( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index b71f6669..5a66eba2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -19,6 +19,8 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.PostCommu import kr.co.vividnext.sodalive.extensions.getTimeAgoString import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.utils.generateFileName @@ -45,6 +47,8 @@ class CreatorCommunityService( private val objectMapper: ObjectMapper, private val audioContentCloudFront: AudioContentCloudFront, private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.s3.bucket}") private val imageBucket: String, @@ -65,11 +69,11 @@ class CreatorCommunityService( val request = objectMapper.readValue(requestString, CreateCommunityPostRequest::class.java) if (request.price > 0 && postImage == null) { - throw SodaException("유료 게시글 등록을 위해서는 이미지가 필요합니다.") + throw SodaException(messageKey = "creator.community.paid_post_image_required") } if (audioFile != null && postImage == null) { - throw SodaException("오디오 등록을 위해서는 이미지가 필요합니다.") + throw SodaException(messageKey = "creator.community.audio_post_image_required") } postImage?.let { validateImage(it, request.price > 0) } @@ -119,7 +123,7 @@ class CreatorCommunityService( FcmEvent( type = FcmEventType.CHANGE_NOTICE, title = member.nickname, - message = "새 글이 등록되었습니다.", + message = messageSource.getMessage("creator.community.fcm.new_post", langContext.lang).orEmpty(), creatorId = member.id!! ) ) @@ -130,7 +134,7 @@ class CreatorCommunityService( val request = objectMapper.readValue(requestString, ModifyCommunityPostRequest::class.java) val post = repository.findByIdAndMemberId(id = request.creatorCommunityId, memberId = member.id!!) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") postImage?.let { validateImage(it, post.price > 0) } @@ -269,10 +273,15 @@ class CreatorCommunityService( isAdult: Boolean ): GetCommunityPostListResponse { val post = repository.getCommunityPost(postId, isAdult = isAdult) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = post.creatorId) - if (isBlocked) throw SodaException("${post.creatorNickname}님의 요청으로 접근이 제한됩니다.") + if (isBlocked) { + val messageTemplate = messageSource + .getMessage("creator.community.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, post.creatorNickname)) + } val isLike = likeRepository.findByPostIdAndMemberId(postId = post.id, memberId = memberId)?.isActive ?: false val likeCount = likeRepository.totalCountCommunityPostLikeByPostId(post.id) @@ -345,7 +354,7 @@ class CreatorCommunityService( postLike.member = member val post = repository.findByIdAndActive(request.postId, isAdult = member.auth != null) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") postLike.creatorCommunity = post likeRepository.save(postLike) @@ -365,11 +374,11 @@ class CreatorCommunityService( isSecret: Boolean = false ) { val post = repository.findByIdOrNull(id = postId) - ?: throw SodaException("잘못된 게시물 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "creator.community.invalid_post_retry") val isExistOrdered = useCanRepository.isExistCommunityPostOrdered(postId = postId, memberId = member.id!!) if (isSecret && !isExistOrdered) { - throw SodaException("게시글을 구매 후 비밀댓글을 등록할 수 있습니다.") + throw SodaException(messageKey = "creator.community.secret_comment_purchase_required") } val postComment = CreatorCommunityComment(comment = comment, isSecret = isSecret) @@ -392,7 +401,7 @@ class CreatorCommunityService( @Transactional fun modifyCommunityPostComment(request: ModifyCommunityPostCommentRequest, member: Member) { val postComment = commentRepository.findByIdOrNull(id = request.commentId) - ?: throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "creator.community.invalid_access_retry") if (request.comment != null && postComment.member!!.id!! == member.id!!) { postComment.comment = request.comment @@ -530,10 +539,15 @@ class CreatorCommunityService( container: String ): GetCommunityPostListResponse { val post = repository.findByIdAndActive(postId, isAdult) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "creator.community.invalid_request_retry") val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = post.member!!.id!!) - if (isBlocked) throw SodaException("${post.member!!.nickname}님의 요청으로 접근이 제한됩니다.") + if (isBlocked) { + val messageTemplate = messageSource + .getMessage("creator.community.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, post.member!!.nickname)) + } val existOrdered = useCanRepository.isExistCommunityPostOrdered(postId = postId, memberId = memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index bffaaf79..90faed6d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -988,6 +988,159 @@ class SodaMessageSource { ) ) + private val explorerSectionMessages = mapOf( + "explorer.section.creator_rank.title" to mapOf( + Lang.KO to "인기 크리에이터", + Lang.EN to "Top creators", + Lang.JA to "人気クリエイター" + ), + "explorer.section.creator_rank.colored_title" to mapOf( + Lang.KO to "인기", + Lang.EN to "Top", + Lang.JA to "人気" + ), + "explorer.section.new_creators.title" to mapOf( + Lang.KO to "새로 시작", + Lang.EN to "New creators", + Lang.JA to "新規クリエイター" + ), + "explorer.section.new_creators.colored_title" to mapOf( + Lang.KO to "새로", + Lang.EN to "New", + Lang.JA to "新規" + ), + "explorer.section.male_creators.title" to mapOf( + Lang.KO to "남자 크리에이터", + Lang.EN to "Male creators", + Lang.JA to "男性クリエイター" + ), + "explorer.section.male_creators.colored_title" to mapOf( + Lang.KO to "남자", + Lang.EN to "Male", + Lang.JA to "男性" + ), + "explorer.section.female_creators.title" to mapOf( + Lang.KO to "여자 크리에이터", + Lang.EN to "Female creators", + Lang.JA to "女性クリエイター" + ), + "explorer.section.female_creators.colored_title" to mapOf( + Lang.KO to "여자", + Lang.EN to "Female", + Lang.JA to "女性" + ) + ) + + private val explorerDateMessages = mapOf( + "explorer.date.creator_rank.start_format" to mapOf( + Lang.KO to "yyyy년 MM월 dd일", + Lang.EN to "MMM dd, yyyy", + Lang.JA to "yyyy年MM月dd日" + ), + "explorer.date.creator_rank.end_format" to mapOf( + Lang.KO to "MM월 dd일", + Lang.EN to "MMM dd", + Lang.JA to "MM月dd日" + ), + "explorer.date.live_room.datetime_format" to mapOf( + Lang.KO to "yyyy년 MM월 dd일 (E) a hh시 mm분", + Lang.EN to "EEE, MMM dd, yyyy h:mm a", + Lang.JA to "yyyy年MM月dd日(E) a hh時 mm分" + ), + "explorer.date.cheers.format" to mapOf( + Lang.KO to "yyyy.MM.dd E hh:mm a", + Lang.EN to "MMM dd, yyyy E hh:mm a", + Lang.JA to "yyyy.MM.dd E hh:mm a" + ) + ) + + private val explorerResponseMessages = mapOf( + "explorer.cheers.created" to mapOf( + Lang.KO to "등록되었습니다.", + Lang.EN to "Registered.", + Lang.JA to "登録されました。" + ), + "explorer.cheers.updated" to mapOf( + Lang.KO to "수정되었습니다.", + Lang.EN to "Updated.", + Lang.JA to "更新されました。" + ), + "explorer.notice.saved" to mapOf( + Lang.KO to "공지사항이 저장되었습니다.", + Lang.EN to "Notice has been saved.", + Lang.JA to "お知らせが保存されました。" + ), + "explorer.notice.fcm.message" to mapOf( + Lang.KO to "새 글이 등록되었습니다.", + Lang.EN to "A new post has been added.", + Lang.JA to "新しい投稿が登録されました。" + ) + ) + + private val explorerValidationMessages = mapOf( + "explorer.search.channel.min_length" to mapOf( + Lang.KO to "두 글자 이상 입력 하셔야 합니다.", + Lang.EN to "Please enter at least 2 characters.", + Lang.JA to "2文字以上入力してください。" + ) + ) + + private val explorerAccessMessages = mapOf( + "explorer.creator.blocked_access" to mapOf( + Lang.KO to "%s님의 요청으로 채널 접근이 제한됩니다.", + Lang.EN to "Channel access is restricted at %s's request.", + Lang.JA to "%sさんの要請によりチャンネルへのアクセスが制限されています。" + ), + "explorer.creator.blocked_cheers" to mapOf( + Lang.KO to "%s님의 요청으로 팬토크 작성이 제한됩니다.", + Lang.EN to "Fan talk posting is restricted at %s's request.", + Lang.JA to "%sさんの要請によりファントークの投稿が制限されています。" + ) + ) + + private val creatorCommunityMessages = mapOf( + "creator.community.paid_post_image_required" to mapOf( + Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.", + Lang.EN to "An image is required to post paid content.", + Lang.JA to "有料投稿を登録するには画像が必要です。" + ), + "creator.community.audio_post_image_required" to mapOf( + Lang.KO to "오디오 등록을 위해서는 이미지가 필요합니다.", + Lang.EN to "An image is required to upload audio.", + Lang.JA to "オーディオを登録するには画像が必要です。" + ), + "creator.community.fcm.new_post" to mapOf( + Lang.KO to "새 글이 등록되었습니다.", + Lang.EN to "A new post has been added.", + Lang.JA to "新しい投稿が登録されました。" + ), + "creator.community.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease try again.", + Lang.JA to "不正なリクエストです。\nもう一度お試しください。" + ), + "creator.community.invalid_post_retry" to mapOf( + Lang.KO to "잘못된 게시물 입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid post.\nPlease try again.", + Lang.JA to "不正な投稿です。\nもう一度お試しください。" + ), + "creator.community.secret_comment_purchase_required" to mapOf( + Lang.KO to "게시글을 구매 후 비밀댓글을 등록할 수 있습니다.", + Lang.EN to "You can post a secret comment after purchasing the post.", + Lang.JA to "投稿を購入した後に秘密コメントを登録できます。" + ), + "creator.community.invalid_access_retry" to mapOf( + Lang.KO to "잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.", + Lang.EN to "Invalid access.\nPlease check and try again.", + Lang.JA to "不正なアクセスです。\n確認して再度お試しください。" + ), + "creator.community.blocked_access" to mapOf( + Lang.KO to "%s님의 요청으로 접근이 제한됩니다.", + Lang.EN to "Access is restricted at %s's request.", + Lang.JA to "%sさんの要請によりアクセスが制限されています。" + ) + ) + fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, @@ -1026,7 +1179,13 @@ class SodaMessageSource { liveRoomMessages, liveRoomMenuMessages, memberProviderMessages, - memberGenderMessages + memberGenderMessages, + explorerSectionMessages, + explorerDateMessages, + explorerResponseMessages, + explorerValidationMessages, + explorerAccessMessages, + creatorCommunityMessages ) for (messages in messageGroups) { val translations = messages[key] ?: continue -- 2.49.1 From f38382d2be88436f3dc19c82f2d4c0ade1b7bcdc Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 16:54:48 +0900 Subject: [PATCH 78/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/CreatorAdminContentController.kt | 6 +- .../content/CreatorAdminContentService.kt | 6 +- .../CreatorAdminCategoryController.kt | 4 +- .../member/CreatorAdminMemberController.kt | 2 +- .../admin/member/CreatorAdminMemberService.kt | 17 ++-- .../CreatorAdminSignatureController.kt | 24 ++++-- .../signature/CreatorAdminSignatureService.kt | 12 +-- .../sodalive/i18n/SodaMessageSource.kt | 77 +++++++++++++++++++ 8 files changed, 120 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt index f19e9737..8fa50be2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentController.kt @@ -23,7 +23,7 @@ class CreatorAdminContentController(private val service: CreatorAdminContentServ pageable: Pageable, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getAudioContentList(pageable, member)) } @@ -34,7 +34,7 @@ class CreatorAdminContentController(private val service: CreatorAdminContentServ @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.searchAudioContent(searchWord, member, pageable)) } @@ -45,7 +45,7 @@ class CreatorAdminContentController(private val service: CreatorAdminContentServ @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.updateAudioContent(coverImage, requestString, member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt index aeaddddd..d0ef8fb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/CreatorAdminContentService.kt @@ -72,7 +72,7 @@ class CreatorAdminContentService( } fun searchAudioContent(searchWord: String, member: Member, pageable: Pageable): GetCreatorAdminContentListResponse { - if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + if (searchWord.length < 2) throw SodaException(messageKey = "creator.admin.content.search_word_min_length") val totalCount = repository.getAudioContentTotalCount( memberId = member.id!!, searchWord @@ -113,7 +113,7 @@ class CreatorAdminContentService( fun updateAudioContent(coverImage: MultipartFile?, requestString: String, member: Member) { val request = objectMapper.readValue(requestString, UpdateCreatorAdminContentRequest::class.java) val audioContent = repository.getAudioContent(memberId = member.id!!, audioContentId = request.id) - ?: throw SodaException("잘못된 콘텐츠 입니다.") + ?: throw SodaException(messageKey = "creator.admin.content.invalid_content") if (coverImage != null) { val metadata = ObjectMetadata() @@ -157,7 +157,7 @@ class CreatorAdminContentService( } if (request.price != null) { - if (request.price < 5) throw SodaException("콘텐츠의 최소금액은 5캔 입니다.") + if (request.price < 5) throw SodaException(messageKey = "creator.admin.content.min_price") val contentPriceChangeLog = ContentPriceChangeLog(prevPrice = audioContent.price) contentPriceChangeLog.audioContent = audioContent diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryController.kt index 37aaecaa..2bd65bb9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/category/CreatorAdminCategoryController.kt @@ -21,7 +21,7 @@ class CreatorAdminCategoryController(private val service: CreatorAdminCategorySe @RequestParam(value = "search_word") searchWord: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchContentNotInCategory( @@ -38,7 +38,7 @@ class CreatorAdminCategoryController(private val service: CreatorAdminCategorySe @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getContentInCategory( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt index 3f142632..bd942a89 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberController.kt @@ -24,7 +24,7 @@ class CreatorAdminMemberController(private val service: CreatorAdminMemberServic @RequestHeader("Authorization") token: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.logout(token.removePrefix("Bearer "), member.id!!)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt index 8ba6b002..9bfd9b76 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/member/CreatorAdminMemberService.kt @@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.creator.admin.member import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.PushTokenService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole @@ -27,6 +29,8 @@ class CreatorAdminMemberService( private val authenticationManagerBuilder: AuthenticationManagerBuilder, private val pushTokenService: PushTokenService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -36,7 +40,7 @@ class CreatorAdminMemberService( fun login(request: LoginRequest): ApiResponse { return ApiResponse.ok( - message = "로그인 되었습니다.", + message = messageSource.getMessage("creator.admin.member.login_success", langContext.lang), data = login(request.email, request.password) ) } @@ -44,7 +48,7 @@ class CreatorAdminMemberService( @Transactional fun logout(token: String, memberId: Long) { val member = repository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") member.pushToken = null pushTokenService.logout(memberId = memberId) @@ -52,7 +56,7 @@ class CreatorAdminMemberService( val lock = getOrCreateLock(memberId = memberId) lock.write { val memberToken = tokenRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") memberToken.tokenSet.remove(token) tokenRepository.save(memberToken) @@ -60,13 +64,14 @@ class CreatorAdminMemberService( } private fun login(email: String, password: String): LoginResponse { - val member = repository.findByEmail(email = email) ?: throw SodaException("로그인 정보를 확인해주세요.") + val member = repository.findByEmail(email = email) + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (!member.isActive) { - throw SodaException("탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.") + throw SodaException(messageKey = "creator.admin.member.inactive_account") } if (member.role != MemberRole.CREATOR) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val authenticationToken = UsernamePasswordAuthenticationToken(email, password) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureController.kt index c9480a4b..1476fa06 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.creator.admin.signature import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable @@ -18,14 +20,18 @@ import org.springframework.web.multipart.MultipartFile @RestController @PreAuthorize("hasRole('CREATOR')") @RequestMapping("/creator-admin/signature") -class CreatorAdminSignatureController(private val service: CreatorAdminSignatureService) { +class CreatorAdminSignatureController( + private val service: CreatorAdminSignatureService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @GetMapping fun getSignatureCanList( pageable: Pageable, @RequestParam("sort-type", required = false) sortType: SignatureCanSortType?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( data = service.getSignatureList( @@ -44,7 +50,7 @@ class CreatorAdminSignatureController(private val service: CreatorAdminSignature @RequestParam("isAdult", required = false) isAdult: Boolean? = false, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.createSignature( @@ -54,7 +60,7 @@ class CreatorAdminSignatureController(private val service: CreatorAdminSignature isAdult = isAdult ?: false, memberId = member.id!! ), - "등록되었습니다." + messageSource.getMessage("creator.admin.signature.created", langContext.lang) ) } @@ -68,9 +74,9 @@ class CreatorAdminSignatureController(private val service: CreatorAdminSignature @RequestParam("isAdult", required = false) isAdult: Boolean?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") if (can == null && time == null && image == null && isActive == null && isAdult == null) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "creator.admin.signature.no_changes") } ApiResponse.ok( @@ -83,7 +89,11 @@ class CreatorAdminSignatureController(private val service: CreatorAdminSignature isAdult = isAdult, memberId = member.id!! ), - if (isActive == false) "삭제되었습니다." else "수정되었습니다." + if (isActive == false) { + messageSource.getMessage("creator.admin.signature.deleted", langContext.lang) + } else { + messageSource.getMessage("creator.admin.signature.updated", langContext.lang) + } ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureService.kt index 6aee5be1..40b6825f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/signature/CreatorAdminSignatureService.kt @@ -44,11 +44,11 @@ class CreatorAdminSignatureService( @Transactional fun createSignature(can: Int, time: Int, image: MultipartFile, memberId: Long, isAdult: Boolean) { - if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.") - if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.") + if (can <= 0) throw SodaException(messageKey = "creator.admin.signature.min_can") + if (time < 3 || time > 20) throw SodaException(messageKey = "creator.admin.signature.time_range") val member = memberRepository.findCreatorByIdOrNull(memberId = memberId) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "creator.admin.signature.invalid_access") val signatureCan = SignatureCan(can = can, time = time, isAdult = isAdult) signatureCan.creator = member @@ -77,15 +77,15 @@ class CreatorAdminSignatureService( isAdult: Boolean? ) { val signatureCan = repository.findSignatureByIdOrNull(id = id, memberId = memberId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "creator.admin.signature.invalid_request") if (can != null) { - if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.") + if (can <= 0) throw SodaException(messageKey = "creator.admin.signature.min_can") signatureCan.can = can } if (time != null) { - if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.") + if (time < 3 || time > 20) throw SodaException(messageKey = "creator.admin.signature.time_range") signatureCan.time = time } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 90faed6d..7e188525 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1098,6 +1098,80 @@ class SodaMessageSource { ) ) + private val creatorAdminMemberMessages = mapOf( + "creator.admin.member.login_success" to mapOf( + Lang.KO to "로그인 되었습니다.", + Lang.EN to "Logged in.", + Lang.JA to "ログインしました。" + ), + "creator.admin.member.inactive_account" to mapOf( + Lang.KO to "탈퇴한 계정입니다.\n고객센터로 문의해 주시기 바랍니다.", + Lang.EN to "This account has been deactivated.\nPlease contact customer support.", + Lang.JA to "退会したアカウントです。\nカスタマーサポートにお問い合わせください。" + ) + ) + + private val creatorAdminSignatureMessages = mapOf( + "creator.admin.signature.created" to mapOf( + Lang.KO to "등록되었습니다.", + Lang.EN to "Successfully registered.", + Lang.JA to "登録されました。" + ), + "creator.admin.signature.updated" to mapOf( + Lang.KO to "수정되었습니다.", + Lang.EN to "Updated.", + Lang.JA to "更新されました。" + ), + "creator.admin.signature.deleted" to mapOf( + Lang.KO to "삭제되었습니다.", + Lang.EN to "Deleted.", + Lang.JA to "削除されました。" + ), + "creator.admin.signature.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes to update.", + Lang.JA to "変更事項がありません。" + ), + "creator.admin.signature.min_can" to mapOf( + Lang.KO to "1캔 이상 설정할 수 있습니다.", + Lang.EN to "You can set at least 1 can.", + Lang.JA to "1缶以上設定できます。" + ), + "creator.admin.signature.time_range" to mapOf( + Lang.KO to "시간은 3초 이상 20초 이하로 설정할 수 있습니다.", + Lang.EN to "Time must be between 3 and 20 seconds.", + Lang.JA to "時間は3秒以上20秒以下に設定できます。" + ), + "creator.admin.signature.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다.", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ), + "creator.admin.signature.invalid_request" to mapOf( + Lang.KO to "잘못된 요청입니다.", + Lang.EN to "Invalid request.", + Lang.JA to "不正なリクエストです。" + ) + ) + + private val creatorAdminContentMessages = mapOf( + "creator.admin.content.search_word_min_length" to mapOf( + Lang.KO to "2글자 이상 입력하세요.", + Lang.EN to "Please enter at least 2 characters.", + Lang.JA to "2文字以上入力してください。" + ), + "creator.admin.content.invalid_content" to mapOf( + Lang.KO to "잘못된 콘텐츠 입니다.", + Lang.EN to "Invalid content.", + Lang.JA to "不正なコンテンツです。" + ), + "creator.admin.content.min_price" to mapOf( + Lang.KO to "콘텐츠의 최소금액은 5캔 입니다.", + Lang.EN to "Minimum price for content is 5 cans.", + Lang.JA to "コンテンツの最低価格は5缶です。" + ) + ) + private val creatorCommunityMessages = mapOf( "creator.community.paid_post_image_required" to mapOf( Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.", @@ -1185,6 +1259,9 @@ class SodaMessageSource { explorerResponseMessages, explorerValidationMessages, explorerAccessMessages, + creatorAdminMemberMessages, + creatorAdminSignatureMessages, + creatorAdminContentMessages, creatorCommunityMessages ) for (messages in messageGroups) { -- 2.49.1 From 291f9a265b415048b6afec99c40f778bbd4596a5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 17:09:59 +0900 Subject: [PATCH 79/90] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 크리에이터 정산/시리즈 API 응답 메시지를 다국어 키로 제공한다. --- .../CreatorAdminCalculateController.kt | 10 +-- .../content/series/CreateSeriesRequest.kt | 12 +-- .../CreatorAdminContentSeriesController.kt | 43 ++++++---- .../CreatorAdminContentSeriesService.kt | 16 ++-- ...reatorAdminContentSeriesGenreController.kt | 2 +- .../sodalive/i18n/SodaMessageSource.kt | 83 +++++++++++++++++++ 6 files changed, 132 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateController.kt index e5f78f64..f11bf75f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/calculate/CreatorAdminCalculateController.kt @@ -21,7 +21,7 @@ class CreatorAdminCalculateController(private val service: CreatorAdminCalculate @RequestParam endDateStr: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr, member)) } @@ -32,7 +32,7 @@ class CreatorAdminCalculateController(private val service: CreatorAdminCalculate @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCalculateContentList( startDateStr, @@ -49,7 +49,7 @@ class CreatorAdminCalculateController(private val service: CreatorAdminCalculate @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCumulativeSalesByContent(member.id!!, pageable.offset, pageable.pageSize.toLong())) } @@ -61,7 +61,7 @@ class CreatorAdminCalculateController(private val service: CreatorAdminCalculate @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCalculateContentDonationList( startDateStr, @@ -80,7 +80,7 @@ class CreatorAdminCalculateController(private val service: CreatorAdminCalculate @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCalculateCommunityPost( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreateSeriesRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreateSeriesRequest.kt index c7e1a914..3f078d97 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreateSeriesRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreateSeriesRequest.kt @@ -26,13 +26,13 @@ data class CreateSeriesRequest( } private fun validate() { - if (title.isBlank()) throw SodaException("시리즈 제목을 입력하세요") - if (introduction.isBlank()) throw SodaException("시리즈 소개를 입력하세요") - if (keyword.isBlank()) throw SodaException("시리즈를 설명할 수 있는 키워드를 입력하세요") - if (genreId <= 0) throw SodaException("올바른 장르를 선택하세요") - if (publishedDaysOfWeek.isEmpty()) throw SodaException("시리즈 연재요일을 선택하세요") + if (title.isBlank()) throw SodaException(messageKey = "creator.admin.series.title_required") + if (introduction.isBlank()) throw SodaException(messageKey = "creator.admin.series.introduction_required") + if (keyword.isBlank()) throw SodaException(messageKey = "creator.admin.series.keyword_required") + if (genreId <= 0) throw SodaException(messageKey = "creator.admin.series.genre_required") + if (publishedDaysOfWeek.isEmpty()) throw SodaException(messageKey = "creator.admin.series.published_days_required") if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM) && publishedDaysOfWeek.size > 1) { - throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.") + throw SodaException(messageKey = "creator.admin.series.published_days_random_exclusive") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesController.kt index ae7f864a..36e899a8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesController.kt @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Pageable import org.springframework.lang.Nullable @@ -23,16 +25,23 @@ import org.springframework.web.multipart.MultipartFile @RestController @PreAuthorize("hasRole('CREATOR')") @RequestMapping("/creator-admin/audio-content/series") -class CreatorAdminContentSeriesController(private val service: CreatorAdminContentSeriesService) { +class CreatorAdminContentSeriesController( + private val service: CreatorAdminContentSeriesService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @PostMapping fun createSeries( @RequestPart("image") image: MultipartFile?, @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - ApiResponse.ok(service.createSeries(image, requestString, member), "시리즈가 생성되었습니다.") + ApiResponse.ok( + service.createSeries(image, requestString, member), + messageSource.getMessage("creator.admin.series.created", langContext.lang) + ) } @PutMapping @@ -43,9 +52,12 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - ApiResponse.ok(service.modifySeries(image, requestString, member), "시리즈가 수정되었습니다.") + ApiResponse.ok( + service.modifySeries(image, requestString, member), + messageSource.getMessage("creator.admin.series.updated", langContext.lang) + ) } @GetMapping @@ -53,7 +65,7 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte pageable: Pageable, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSeriesList( @@ -69,7 +81,7 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getDetail(id = id, memberId = member.id!!)) } @@ -80,7 +92,7 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSeriesContent( @@ -97,11 +109,11 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @RequestBody request: AddingContentToTheSeriesRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.addingContentToTheSeries(request, memberId = member.id!!), - "콘텐츠가 추가되었습니다." + messageSource.getMessage("creator.admin.series.content_added", langContext.lang) ) } @@ -110,11 +122,11 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @RequestBody request: RemoveContentToTheSeriesRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.removeContentInTheSeries(request, memberId = member.id!!), - "콘텐츠를 삭제하였습니다." + messageSource.getMessage("creator.admin.series.content_removed", langContext.lang) ) } @@ -124,7 +136,7 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @RequestParam(value = "search_word") searchWord: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.searchContentNotInSeries( @@ -138,5 +150,8 @@ class CreatorAdminContentSeriesController(private val service: CreatorAdminConte @PutMapping("/orders") fun updateSeriesOrders( @RequestBody request: UpdateOrdersRequest - ) = ApiResponse.ok(service.updateSeriesOrders(ids = request.ids), "수정되었습니다.") + ) = ApiResponse.ok( + service.updateSeriesOrders(ids = request.ids), + messageSource.getMessage("creator.admin.series.orders_updated", langContext.lang) + ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt index 739aed22..105e3350 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt @@ -45,7 +45,7 @@ class CreatorAdminContentSeriesService( ) { @Transactional fun createSeries(coverImage: MultipartFile?, requestString: String, member: Member) { - if (coverImage == null) throw SodaException("커버이미지를 선택해 주세요.") + if (coverImage == null) throw SodaException(messageKey = "creator.admin.series.cover_image_required") val request = objectMapper.readValue(requestString, CreateSeriesRequest::class.java) val series = request.toSeries() @@ -139,11 +139,11 @@ class CreatorAdminContentSeriesService( request.studio == null && request.isActive == null ) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "creator.admin.series.no_changes") } val series = repository.findByIdAndCreatorId(id = request.seriesId, creatorId = member.id!!) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "creator.admin.series.invalid_access") if (coverImage != null) { val metadata = ObjectMetadata() @@ -175,7 +175,7 @@ class CreatorAdminContentSeriesService( request.publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM) && request.publishedDaysOfWeek.size > 1 ) { - throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.") + throw SodaException(messageKey = "creator.admin.series.published_days_random_exclusive") } series.publishedDaysOfWeek.clear() @@ -245,7 +245,7 @@ class CreatorAdminContentSeriesService( fun getDetail(id: Long, memberId: Long): GetCreatorAdminContentSeriesDetailResponse { val series = repository.findByIdAndCreatorId(id = id, creatorId = memberId) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "creator.admin.series.invalid_access") return series.toDetailResponse(imageHost = coverImageHost) } @@ -271,7 +271,7 @@ class CreatorAdminContentSeriesService( @Transactional fun addingContentToTheSeries(request: AddingContentToTheSeriesRequest, memberId: Long) { val series = repository.findByIdAndCreatorId(id = request.seriesId, creatorId = memberId) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "creator.admin.series.invalid_access") val seriesContentList = mutableListOf() @@ -288,14 +288,14 @@ class CreatorAdminContentSeriesService( if (seriesContentList.size > 0) { series.contentList.addAll(seriesContentList.toSet()) } else { - throw SodaException("추가된 콘텐츠가 없습니다.") + throw SodaException(messageKey = "creator.admin.series.no_content_added") } } @Transactional fun removeContentInTheSeries(request: RemoveContentToTheSeriesRequest, memberId: Long) { val series = repository.findByIdAndCreatorId(id = request.seriesId, creatorId = memberId) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "creator.admin.series.invalid_access") series.contentList.removeIf { it.content!!.id == request.contentId } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/genre/CreatorAdminContentSeriesGenreController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/genre/CreatorAdminContentSeriesGenreController.kt index 48004c5b..188c913b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/genre/CreatorAdminContentSeriesGenreController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/genre/CreatorAdminContentSeriesGenreController.kt @@ -17,7 +17,7 @@ class CreatorAdminContentSeriesGenreController(private val service: CreatorAdmin fun getGenreList( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getGenreList(isAdult = member.auth != null)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 7e188525..f82574cf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -1172,6 +1172,87 @@ class SodaMessageSource { ) ) + private val creatorAdminSeriesRequestMessages = mapOf( + "creator.admin.series.title_required" to mapOf( + Lang.KO to "시리즈 제목을 입력하세요", + Lang.EN to "Please enter a series title.", + Lang.JA to "シリーズのタイトルを入力してください。" + ), + "creator.admin.series.introduction_required" to mapOf( + Lang.KO to "시리즈 소개를 입력하세요", + Lang.EN to "Please enter a series introduction.", + Lang.JA to "シリーズ紹介を入力してください。" + ), + "creator.admin.series.keyword_required" to mapOf( + Lang.KO to "시리즈를 설명할 수 있는 키워드를 입력하세요", + Lang.EN to "Please enter keywords that describe the series.", + Lang.JA to "シリーズを説明できるキーワードを入力してください。" + ), + "creator.admin.series.genre_required" to mapOf( + Lang.KO to "올바른 장르를 선택하세요", + Lang.EN to "Please select a valid genre.", + Lang.JA to "正しいジャンルを選択してください。" + ), + "creator.admin.series.published_days_required" to mapOf( + Lang.KO to "시리즈 연재요일을 선택하세요", + Lang.EN to "Please select publishing days.", + Lang.JA to "シリーズの連載曜日を選択してください。" + ), + "creator.admin.series.published_days_random_exclusive" to mapOf( + Lang.KO to "랜덤과 연재요일 동시에 선택할 수 없습니다.", + Lang.EN to "You cannot select random and specific days at the same time.", + Lang.JA to "ランダムと連載曜日を同時に選択することはできません。" + ), + "creator.admin.series.cover_image_required" to mapOf( + Lang.KO to "커버이미지를 선택해 주세요.", + Lang.EN to "Please select a cover image.", + Lang.JA to "カバー画像を選択してください。" + ), + "creator.admin.series.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes to update.", + Lang.JA to "変更事項がありません。" + ), + "creator.admin.series.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다.", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ), + "creator.admin.series.no_content_added" to mapOf( + Lang.KO to "추가된 콘텐츠가 없습니다.", + Lang.EN to "No content was added.", + Lang.JA to "追加されたコンテンツがありません。" + ) + ) + + private val creatorAdminSeriesMessages = mapOf( + "creator.admin.series.created" to mapOf( + Lang.KO to "시리즈가 생성되었습니다.", + Lang.EN to "Series created.", + Lang.JA to "シリーズが作成されました。" + ), + "creator.admin.series.updated" to mapOf( + Lang.KO to "시리즈가 수정되었습니다.", + Lang.EN to "Series updated.", + Lang.JA to "シリーズが更新されました。" + ), + "creator.admin.series.content_added" to mapOf( + Lang.KO to "콘텐츠가 추가되었습니다.", + Lang.EN to "Content added.", + Lang.JA to "コンテンツが追加されました。" + ), + "creator.admin.series.content_removed" to mapOf( + Lang.KO to "콘텐츠를 삭제하였습니다.", + Lang.EN to "Content removed.", + Lang.JA to "コンテンツが削除されました。" + ), + "creator.admin.series.orders_updated" to mapOf( + Lang.KO to "수정되었습니다.", + Lang.EN to "Updated.", + Lang.JA to "更新されました。" + ) + ) + private val creatorCommunityMessages = mapOf( "creator.community.paid_post_image_required" to mapOf( Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.", @@ -1262,6 +1343,8 @@ class SodaMessageSource { creatorAdminMemberMessages, creatorAdminSignatureMessages, creatorAdminContentMessages, + creatorAdminSeriesRequestMessages, + creatorAdminSeriesMessages, creatorCommunityMessages ) for (messages in messageGroups) { -- 2.49.1 From 7ef654e89d821c644f6c125bec710aac5d68a98d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 17:35:38 +0900 Subject: [PATCH 80/90] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/banner/AdminEventBannerService.kt | 14 +- .../event/charge/AdminChargeEventService.kt | 2 +- .../admin/explorer/AdminExplorerService.kt | 14 +- .../sodalive/admin/live/AdminLiveService.kt | 46 ++-- .../signature/AdminSignatureCanController.kt | 14 +- .../signature/AdminSignatureCanService.kt | 14 +- .../marketing/AdminAdMediaPartnerService.kt | 2 +- .../admin/member/AdminMemberService.kt | 32 +-- .../admin/member/tag/AdminMemberTagService.kt | 8 +- .../admin/point/PointPolicyService.kt | 2 +- .../member/AdminMemberStatisticsService.kt | 2 +- .../sodalive/i18n/SodaMessageSource.kt | 215 ++++++++++++++++++ 12 files changed, 305 insertions(+), 60 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt index a762b8ca..7aa7bacb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt @@ -34,7 +34,9 @@ class AdminEventBannerService( startDateString: String, endDateString: String ): Long { - if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요") + if (detail == null && link.isNullOrBlank()) { + throw SodaException(messageKey = "admin.event.banner.detail_or_link_required") + } val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter) @@ -102,7 +104,7 @@ class AdminEventBannerService( event.detailImage = detailImagePath event.popupImage = popupImagePath - return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.") + return event.id ?: throw SodaException(messageKey = "admin.event.banner.create_failed") } @Transactional @@ -118,10 +120,10 @@ class AdminEventBannerService( startDateString: String? = null, endDateString: String? = null ) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val event = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (thumbnail != null) { val metadata = ObjectMetadata() @@ -190,9 +192,9 @@ class AdminEventBannerService( @Transactional fun delete(id: Long) { - if (id <= 0) throw SodaException("잘못된 요청입니다.") + if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request") val event = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") event.isActive = false } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventService.kt index c36d54e9..01e3025f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/charge/AdminChargeEventService.kt @@ -38,7 +38,7 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository @Transactional fun modifyChargeEvent(request: ModifyChargeEventRequest) { val chargeEvent = repository.findByIdOrNull(request.id) - ?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "admin.charge_event.not_found_retry") if (request.title != null) { chargeEvent.title = request.title diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt index 7bab12db..4538ea9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/explorer/AdminExplorerService.kt @@ -17,10 +17,10 @@ class AdminExplorerService( ) { @Transactional fun createExplorerSection(request: CreateExplorerSectionRequest): Long { - if (request.title.isBlank()) throw SodaException("제목을 입력하세요.") + if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.title_required") val findExplorerSection = repository.findByTitle(request.title) - if (findExplorerSection != null) throw SodaException("동일한 제목이 있습니다.") + if (findExplorerSection != null) throw SodaException(messageKey = "admin.explorer.title_duplicate") val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult) explorerSection.coloredTitle = request.coloredTitle @@ -37,7 +37,7 @@ class AdminExplorerService( } } - if (tags.size <= 0) throw SodaException("관심사를 선택하세요.") + if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_required") explorerSection.tags = tags return repository.save(explorerSection).id!! @@ -53,14 +53,14 @@ class AdminExplorerService( request.coloredTitle == null && request.isActive == null ) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "admin.explorer.no_changes") } val explorerSection = repository.findByIdOrNull(request.id) - ?: throw SodaException("해당하는 섹션이 없습니다.") + ?: throw SodaException(messageKey = "admin.explorer.section_not_found") if (request.title != null) { - if (request.title.isBlank()) throw SodaException("올바른 제목을 입력하세요.") + if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.valid_title_required") explorerSection.title = request.title } @@ -97,7 +97,7 @@ class AdminExplorerService( } } - if (tags.size <= 0) throw SodaException("관심사를 입력하세요.") + if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_input_required") if (tags != explorerSection.tags) { explorerSection.tags.clear() explorerSection.tags.addAll(tags) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt index f1fc0651..969ec154 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/AdminLiveService.kt @@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository @@ -49,6 +51,8 @@ class AdminLiveService( private val canRepository: CanRepository, private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.s3.bucket}") private val bucket: String, @@ -118,10 +122,10 @@ class AdminLiveService( endDateString: String, isAdult: Boolean ): Long { - if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.") + if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required") val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId) - ?: throw SodaException("올바른 크리에이터를 선택해 주세요.") + ?: throw SodaException(messageKey = "admin.live.creator_required") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter) @@ -134,15 +138,15 @@ class AdminLiveService( .withZoneSameInstant(ZoneId.of("UTC")) .toLocalDateTime() - if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.") + if (startDate < nowDate) throw SodaException(messageKey = "admin.live.start_after_now") val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter) .atZone(ZoneId.of("Asia/Seoul")) .withZoneSameInstant(ZoneId.of("UTC")) .toLocalDateTime() - if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.") - if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.") + if (endDate < nowDate) throw SodaException(messageKey = "admin.live.end_after_now") + if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end") val recommendCreatorBanner = RecommendLiveCreatorBanner( startDate = startDate, @@ -176,13 +180,13 @@ class AdminLiveService( isAdult: Boolean? ) { val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId) - ?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.") + ?: throw SodaException(messageKey = "admin.live.recommend_not_found_retry") if (creatorId != null) { - if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.") + if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required") val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId) - ?: throw SodaException("올바른 크리에이터를 선택해 주세요.") + ?: throw SodaException(messageKey = "admin.live.creator_required") recommendCreatorBanner.creator = creator } @@ -218,13 +222,13 @@ class AdminLiveService( if (endDate != null) { if (endDate <= startDate) { - throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.") + throw SodaException(messageKey = "admin.live.start_before_end") } recommendCreatorBanner.endDate = endDate } else { if (recommendCreatorBanner.endDate <= startDate) { - throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.") + throw SodaException(messageKey = "admin.live.start_before_end") } } @@ -237,7 +241,7 @@ class AdminLiveService( .toLocalDateTime() if (endDate <= recommendCreatorBanner.startDate) { - throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.") + throw SodaException(messageKey = "admin.live.end_after_start") } recommendCreatorBanner.endDate = endDate @@ -266,7 +270,10 @@ class AdminLiveService( for (room in findRoomList) { room.isActive = false - val roomCancel = LiveRoomCancel("관리자에 의한 취소 - 노쇼") + val cancelReason = messageSource + .getMessage("admin.live.cancel_reason.no_show", langContext.lang) + .orEmpty() + val roomCancel = LiveRoomCancel(cancelReason) roomCancel.room = room roomCancelRepository.save(roomCancel) @@ -286,7 +293,10 @@ class AdminLiveService( it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + val canTitleTemplate = messageSource + .getMessage("live.room.can_title", langContext.lang) + .orEmpty() + charge.title = String.format(canTitleTemplate, it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -300,7 +310,9 @@ class AdminLiveService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "환불" + payment.method = messageSource + .getMessage("live.room.refund_method", langContext.lang) + .orEmpty() charge.payment = payment chargeRepository.save(charge) @@ -313,11 +325,15 @@ class AdminLiveService( reservationRepository.cancelReservation(roomId = room.id!!) // 라이브 취소 푸시 발송 + val cancelMessageTemplate = messageSource + .getMessage("live.room.fcm.message.canceled", langContext.lang) + .orEmpty() + val cancelMessage = String.format(cancelMessageTemplate, room.title) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.CANCEL_LIVE, title = room.member!!.nickname, - message = "라이브 취소 : ${room.title}", + message = cancelMessage, recipientsMap = pushTokenListMap ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanController.kt index 435d417c..6281596c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.admin.live.signature import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType import org.springframework.data.domain.Pageable import org.springframework.security.access.prepost.PreAuthorize @@ -16,7 +18,11 @@ import org.springframework.web.multipart.MultipartFile @RestController @PreAuthorize("hasRole('ADMIN')") @RequestMapping("/admin/live/signature-can") -class AdminSignatureCanController(private val service: AdminSignatureCanService) { +class AdminSignatureCanController( + private val service: AdminSignatureCanService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @GetMapping fun getSignatureCanList( pageable: Pageable, @@ -32,7 +38,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService) @RequestParam("isAdult", required = false) isAdult: Boolean = false ) = ApiResponse.ok( service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult), - "등록되었습니다." + messageSource.getMessage("admin.signature_can.created", langContext.lang) ) @PutMapping @@ -45,7 +51,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService) @RequestParam("isAdult", required = false) isAdult: Boolean? ) = run { if (can == null && time == null && image == null && isActive == null && isAdult == null) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "admin.signature_can.no_changes") } ApiResponse.ok( @@ -57,7 +63,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService) isActive = isActive, isAdult = isAdult ), - "수정되었습니다." + messageSource.getMessage("admin.signature_can.updated", langContext.lang) ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanService.kt index ae5f3011..212a5aff 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/live/signature/AdminSignatureCanService.kt @@ -43,12 +43,12 @@ class AdminSignatureCanService( @Transactional fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) { - if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.") - if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.") - if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.") + if (creatorId < 1) throw SodaException(messageKey = "admin.signature_can.creator_required") + if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can") + if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range") val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId) - ?: throw SodaException("올바른 크리에이터를 선택해 주세요.") + ?: throw SodaException(messageKey = "admin.signature_can.creator_required") val signatureCan = SignatureCan(can = can, isAdult = isAdult) signatureCan.creator = creator @@ -76,15 +76,15 @@ class AdminSignatureCanService( isAdult: Boolean? ) { val signatureCan = repository.findByIdOrNull(id = id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (can != null) { - if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.") + if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can") signatureCan.can = can } if (time != null) { - if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.") + if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range") signatureCan.time = time } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt index 3e454c73..5b0441e7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/marketing/AdminAdMediaPartnerService.kt @@ -20,7 +20,7 @@ class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepositor @Transactional fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) { val entity = repository.findByIdOrNull(request.id) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "admin.media_partner.invalid_access") if (request.mediaGroup != null) { entity.mediaGroup = request.mediaGroup diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt index 5a1f50f1..29e59710 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/AdminMemberService.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.admin.member import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberProvider import kr.co.vividnext.sodalive.member.MemberRole @@ -17,6 +19,8 @@ import java.time.format.DateTimeFormatter class AdminMemberService( private val repository: AdminMemberRepository, private val passwordEncoder: PasswordEncoder, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -24,7 +28,7 @@ class AdminMemberService( @Transactional fun updateMember(request: UpdateMemberRequest) { val member = repository.findByIdOrNull(request.id) - ?: throw SodaException("해당 유저가 없습니다.") + ?: throw SodaException(messageKey = "admin.member.not_found") if (member.role != request.userType) { member.role = request.userType @@ -44,7 +48,7 @@ class AdminMemberService( } fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse { - if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length") val totalCount = repository.searchMemberTotalCount(searchWord = searchWord) val memberList = processMemberListToGetAdminMemberListResponseItemList( memberList = repository.searchMember( @@ -71,7 +75,7 @@ class AdminMemberService( } fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse { - if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length") val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR) val creatorList = processMemberListToGetAdminMemberListResponseItemList( memberList = repository.searchMember( @@ -92,18 +96,18 @@ class AdminMemberService( .asSequence() .map { val userType = when (it.role) { - MemberRole.ADMIN -> "관리자" - MemberRole.USER -> "일반회원" - MemberRole.CREATOR -> "크리에이터" - MemberRole.AGENT -> "에이전트" - MemberRole.BOT -> "봇" + MemberRole.ADMIN -> messageSource.getMessage("admin.member.role.admin", langContext.lang).orEmpty() + MemberRole.USER -> messageSource.getMessage("admin.member.role.user", langContext.lang).orEmpty() + MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty() + MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty() + MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty() } val loginType = when (it.provider) { - MemberProvider.EMAIL -> "이메일" - MemberProvider.KAKAO -> "카카오" - MemberProvider.GOOGLE -> "구글" - MemberProvider.APPLE -> "애플" + MemberProvider.EMAIL -> messageSource.getMessage("member.provider.email", langContext.lang).orEmpty() + MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty() + MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty() + MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty() } val signUpDate = it.createdAt!! @@ -146,7 +150,7 @@ class AdminMemberService( } fun searchMemberByNickname(searchWord: String, size: Int = 20): List { - if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.") + if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length") val limit = if (size <= 0) 20 else size return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong()) } @@ -154,7 +158,7 @@ class AdminMemberService( @Transactional fun resetPassword(request: ResetPasswordRequest) { val member = repository.findByIdAndActive(memberId = request.memberId) - ?: throw SodaException("잘못된 회원정보입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "admin.member.reset_password_invalid") member.password = passwordEncoder.encode(member.email.split("@")[0]) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt index f3c5806e..2c49bdaa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/member/tag/AdminMemberTagService.kt @@ -35,7 +35,9 @@ class AdminMemberTagService( } private fun tagExistCheck(request: CreateMemberTagRequest) { - repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") } + repository.findByTag(request.tag)?.let { + throw SodaException(messageKey = "admin.member.tag.already_registered") + } } private fun createTag(tag: String, imagePath: String, isAdult: Boolean) { @@ -51,7 +53,7 @@ class AdminMemberTagService( @Transactional fun deleteTag(id: Long) { val creatorTag = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") creatorTag.tag = "${creatorTag.tag}_deleted" creatorTag.isActive = false @@ -60,7 +62,7 @@ class AdminMemberTagService( @Transactional fun modifyTag(id: Long, image: MultipartFile?, requestString: String) { val creatorTag = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java) creatorTag.tag = request.tag diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt index c179114e..f257d7a2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/point/PointPolicyService.kt @@ -26,7 +26,7 @@ class PointPolicyService(private val repository: PointPolicyRepository) { @Transactional fun update(id: Long, request: ModifyPointRewardPolicyRequest) { val pointPolicy = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 접근입니다.") + ?: throw SodaException(messageKey = "admin.point.policy.invalid_access") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt index ce085564..e4a197da 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/statistics/member/AdminMemberStatisticsService.kt @@ -32,7 +32,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics ) if (dateRange == null) { - throw SodaException("잘못된 접근입니다.") + throw SodaException(messageKey = "admin.member.statistics.invalid_access") } var startDateTime = startDate.atStartOfDay() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index f82574cf..acf9e416 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -389,6 +389,211 @@ class SodaMessageSource { ) ) + private val adminEventBannerMessages = mapOf( + "admin.event.banner.detail_or_link_required" to mapOf( + Lang.KO to "상세이미지 혹은 링크를 등록하세요", + Lang.EN to "Please register a detail image or a link.", + Lang.JA to "詳細画像またはリンクを登録してください。" + ), + "admin.event.banner.create_failed" to mapOf( + Lang.KO to "이벤트 등록을 하지 못했습니다.", + Lang.EN to "Failed to create the event.", + Lang.JA to "イベントの登録に失敗しました。" + ) + ) + + private val adminChargeEventMessages = mapOf( + "admin.charge_event.not_found_retry" to mapOf( + Lang.KO to "해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.", + Lang.EN to "Charge event not found.\nPlease try again.", + Lang.JA to "該当するチャージイベントがありません。\nもう一度お試しください。" + ) + ) + + private val adminExplorerMessages = mapOf( + "admin.explorer.title_required" to mapOf( + Lang.KO to "제목을 입력하세요.", + Lang.EN to "Please enter a title.", + Lang.JA to "タイトルを入力してください。" + ), + "admin.explorer.title_duplicate" to mapOf( + Lang.KO to "동일한 제목이 있습니다.", + Lang.EN to "A section with the same title already exists.", + Lang.JA to "同じタイトルが存在します。" + ), + "admin.explorer.tags_required" to mapOf( + Lang.KO to "관심사를 선택하세요.", + Lang.EN to "Please select interests.", + Lang.JA to "関心事を選択してください。" + ), + "admin.explorer.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes to update.", + Lang.JA to "変更事項がありません。" + ), + "admin.explorer.section_not_found" to mapOf( + Lang.KO to "해당하는 섹션이 없습니다.", + Lang.EN to "Section not found.", + Lang.JA to "該当するセクションがありません。" + ), + "admin.explorer.valid_title_required" to mapOf( + Lang.KO to "올바른 제목을 입력하세요.", + Lang.EN to "Please enter a valid title.", + Lang.JA to "正しいタイトルを入力してください。" + ), + "admin.explorer.tags_input_required" to mapOf( + Lang.KO to "관심사를 입력하세요.", + Lang.EN to "Please enter interests.", + Lang.JA to "関心事を入力してください。" + ) + ) + + private val adminLiveMessages = mapOf( + "admin.live.creator_required" to mapOf( + Lang.KO to "올바른 크리에이터를 선택해 주세요.", + Lang.EN to "Please select a valid creator.", + Lang.JA to "正しいクリエイターを選択してください。" + ), + "admin.live.start_after_now" to mapOf( + Lang.KO to "노출 시작일은 현재시간 이후로 설정하셔야 합니다.", + Lang.EN to "Start date must be set after the current time.", + Lang.JA to "表示開始日は現在時刻より後に設定してください。" + ), + "admin.live.end_after_now" to mapOf( + Lang.KO to "노출 종료일은 현재시간 이후로 설정하셔야 합니다.", + Lang.EN to "End date must be set after the current time.", + Lang.JA to "表示終了日は現在時刻より後に設定してください。" + ), + "admin.live.start_before_end" to mapOf( + Lang.KO to "노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.", + Lang.EN to "Start date must be before the end date.", + Lang.JA to "表示開始日は表示終了日より前に設定してください。" + ), + "admin.live.recommend_not_found_retry" to mapOf( + Lang.KO to "해당하는 추천라이브가 없습니다. 다시 확인해 주세요.", + Lang.EN to "Recommended live not found. Please check again.", + Lang.JA to "該当するおすすめライブがありません。もう一度確認してください。" + ), + "admin.live.end_after_start" to mapOf( + Lang.KO to "노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.", + Lang.EN to "End date must be after the start date.", + Lang.JA to "表示終了日は表示開始日より後に設定してください。" + ), + "admin.live.cancel_reason.no_show" to mapOf( + Lang.KO to "관리자에 의한 취소 - 노쇼", + Lang.EN to "Canceled by admin - no-show", + Lang.JA to "管理者によるキャンセル - ノーショー" + ) + ) + + private val adminSignatureCanMessages = mapOf( + "admin.signature_can.created" to mapOf( + Lang.KO to "등록되었습니다.", + Lang.EN to "Successfully registered.", + Lang.JA to "登録されました。" + ), + "admin.signature_can.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes to update.", + Lang.JA to "変更事項がありません。" + ), + "admin.signature_can.updated" to mapOf( + Lang.KO to "수정되었습니다.", + Lang.EN to "Updated.", + Lang.JA to "更新されました。" + ), + "admin.signature_can.creator_required" to mapOf( + Lang.KO to "올바른 크리에이터를 선택해 주세요.", + Lang.EN to "Please select a valid creator.", + Lang.JA to "正しいクリエイターを選択してください。" + ), + "admin.signature_can.min_can" to mapOf( + Lang.KO to "1캔 이상 설정할 수 있습니다.", + Lang.EN to "You can set at least 1 can.", + Lang.JA to "1缶以上設定できます。" + ), + "admin.signature_can.time_range" to mapOf( + Lang.KO to "시간은 3초 이상 20초 이하로 설정할 수 있습니다.", + Lang.EN to "Time must be between 3 and 20 seconds.", + Lang.JA to "時間は3秒以上20秒以下に設定できます。" + ) + ) + + private val adminAdMediaPartnerMessages = mapOf( + "admin.media_partner.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ) + ) + + private val adminMemberMessages = mapOf( + "admin.member.not_found" to mapOf( + Lang.KO to "해당 유저가 없습니다.", + Lang.EN to "User not found.", + Lang.JA to "該当するユーザーがいません。" + ), + "admin.member.search_word_min_length" to mapOf( + Lang.KO to "2글자 이상 입력하세요.", + Lang.EN to "Please enter at least 2 characters.", + Lang.JA to "2文字以上入力してください。" + ), + "admin.member.reset_password_invalid" to mapOf( + Lang.KO to "잘못된 회원정보입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid member information.\nPlease try again.", + Lang.JA to "不正な会員情報です。\nもう一度お試しください。" + ), + "admin.member.role.admin" to mapOf( + Lang.KO to "관리자", + Lang.EN to "Admin", + Lang.JA to "管理者" + ), + "admin.member.role.user" to mapOf( + Lang.KO to "일반회원", + Lang.EN to "User", + Lang.JA to "一般会員" + ), + "admin.member.role.creator" to mapOf( + Lang.KO to "크리에이터", + Lang.EN to "Creator", + Lang.JA to "クリエイター" + ), + "admin.member.role.agent" to mapOf( + Lang.KO to "에이전트", + Lang.EN to "Agent", + Lang.JA to "エージェント" + ), + "admin.member.role.bot" to mapOf( + Lang.KO to "봇", + Lang.EN to "Bot", + Lang.JA to "ボット" + ) + ) + + private val adminMemberTagMessages = mapOf( + "admin.member.tag.already_registered" to mapOf( + Lang.KO to "이미 등록된 태그 입니다.", + Lang.EN to "Tag already registered.", + Lang.JA to "既に登録されたタグです。" + ) + ) + + private val adminPointPolicyMessages = mapOf( + "admin.point.policy.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다.", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ) + ) + + private val adminMemberStatisticsMessages = mapOf( + "admin.member.statistics.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다.", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ) + ) + private val messageMessages = mapOf( "message.error.recipient_not_found" to mapOf( Lang.KO to "받는 사람이 없습니다.", @@ -1318,6 +1523,16 @@ class SodaMessageSource { adminContentSeriesBannerMessages, adminContentSeriesGenreMessages, adminContentThemeMessages, + adminEventBannerMessages, + adminChargeEventMessages, + adminExplorerMessages, + adminLiveMessages, + adminSignatureCanMessages, + adminAdMediaPartnerMessages, + adminMemberMessages, + adminMemberTagMessages, + adminPointPolicyMessages, + adminMemberStatisticsMessages, messageMessages, noticeMessages, reportMessages, -- 2.49.1 From 58f7a8654bcbceebf16a252ca0f6a6926b1244e1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 17:45:47 +0900 Subject: [PATCH 81/90] =?UTF-8?q?=EC=95=8C=EB=A6=BC/=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=85=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림/오디션 오류 응답 메시지를 키 기반 다국어로 분리 --- .../sodalive/alarm/AlarmController.kt | 4 +-- .../vividnext/sodalive/alarm/AlarmService.kt | 2 +- .../sodalive/audition/AuditionController.kt | 2 +- .../applicant/AuditionApplicantController.kt | 4 +-- .../applicant/AuditionApplicantService.kt | 4 +-- .../audition/role/AuditionRoleController.kt | 2 +- .../audition/role/AuditionRoleService.kt | 2 +- .../audition/vote/AuditionVoteController.kt | 2 +- .../audition/vote/AuditionVoteService.kt | 4 +-- .../sodalive/i18n/SodaMessageSource.kt | 36 +++++++++++++++++++ 10 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmController.kt index 2428b4f2..e5821569 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmController.kt @@ -17,7 +17,7 @@ class AlarmController(private val service: AlarmService) { fun getSlotQuantityAndPrice( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSlotQuantityAndPrice(memberId = member.id!!) @@ -29,7 +29,7 @@ class AlarmController(private val service: AlarmService) { @PathVariable("container") container: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.buyExtraSlot( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmService.kt index 9881eedc..67dbc60a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/alarm/AlarmService.kt @@ -53,7 +53,7 @@ class AlarmService( } else -> { - throw SodaException("이미 구매하셨습니다") + throw SodaException(messageKey = "alarm.error.already_purchased") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/AuditionController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/AuditionController.kt index 527d4f17..f1579d74 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/AuditionController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/AuditionController.kt @@ -32,7 +32,7 @@ class AuditionController(private val service: AuditionService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getAuditionDetail(auditionId = id)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantController.kt index 9caeb2d9..af101659 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantController.kt @@ -23,7 +23,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService) @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAuditionApplicantList( @@ -42,7 +42,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService) @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.applyAuditionRole( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt index 92c410e5..d7a8e7de 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/applicant/AuditionApplicantService.kt @@ -47,11 +47,11 @@ class AuditionApplicantService( @Transactional fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) { - if (contentFile == null) throw SodaException("녹음 파일을 확인해 주세요.") + if (contentFile == null) throw SodaException(messageKey = "audition.applicant.content_file_required") val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java) val auditionRole = roleRepository.findByIdOrNull(id = request.roleId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "audition.error.invalid_request_retry") val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId( memberId = member.id!!, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleController.kt index 5f7a48bb..03943982 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleController.kt @@ -17,7 +17,7 @@ class AuditionRoleController(private val service: AuditionRoleService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAuditionRoleDetail( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleService.kt index c1e05d3b..72d83b06 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/role/AuditionRoleService.kt @@ -11,7 +11,7 @@ class AuditionRoleService( ) { fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse { val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "audition.error.invalid_request_retry") val isAlreadyApplicant = applicantRepository.isAlreadyApplicant( auditionRoleId = auditionRoleId, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteController.kt index 434783be..a8a6cde3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteController.kt @@ -19,7 +19,7 @@ class AuditionVoteController( @RequestBody request: VoteAuditionApplicantRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.voteAuditionApplicant( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteService.kt index fa0ea928..cbe3150b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/audition/vote/AuditionVoteService.kt @@ -20,7 +20,7 @@ class AuditionVoteService( ) { fun voteAuditionApplicant(applicantId: Long, timezone: String, container: String, member: Member) { val applicant = applicantRepository.findByIdOrNull(applicantId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "audition.error.invalid_request_retry") val defaultZoneId = ZoneId.of("Asia/Seoul") val clientZoneId = try { @@ -43,7 +43,7 @@ class AuditionVoteService( ) if (voteCount > 100) { - throw SodaException("오늘 응원은 여기까지!\n하루 최대 100회까지 응원이 가능합니다.\n내일 다시 이용해주세요.") + throw SodaException(messageKey = "audition.vote.max_daily_reached") } if (voteCount > 0) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index acf9e416..04940ef5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -42,6 +42,14 @@ class SodaMessageSource { ) ) + private val alarmMessages = mapOf( + "alarm.error.already_purchased" to mapOf( + Lang.KO to "이미 구매하셨습니다", + Lang.EN to "You have already purchased this", + Lang.JA to "すでに購入済みです" + ) + ) + private val auditionMessages = mapOf( "admin.audition.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", @@ -114,6 +122,30 @@ class SodaMessageSource { ) ) + private val auditionClientMessages = mapOf( + "audition.error.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease try again.", + Lang.JA to "不正なリクエストです。\nもう一度お試しください。" + ) + ) + + private val auditionApplicantMessages = mapOf( + "audition.applicant.content_file_required" to mapOf( + Lang.KO to "녹음 파일을 확인해 주세요.", + Lang.EN to "Please check the recording file.", + Lang.JA to "録音ファイルを確認してください。" + ) + ) + + private val auditionVoteMessages = mapOf( + "audition.vote.max_daily_reached" to mapOf( + Lang.KO to "오늘 응원은 여기까지!\n하루 최대 100회까지 응원이 가능합니다.\n내일 다시 이용해주세요.", + Lang.EN to "That's all for today!\nYou can vote up to 100 times per day.\nPlease try again tomorrow.", + Lang.JA to "今日はここまでです!\n1日に最大100回まで応援できます。\n明日またご利用ください。" + ) + ) + private val settlementRatioMessages = mapOf( "admin.settlement_ratio.invalid_creator" to mapOf( Lang.KO to "잘못된 크리에이터 입니다.", @@ -1504,10 +1536,14 @@ class SodaMessageSource { fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, + alarmMessages, auditionMessages, auditionRequestMessages, auditionNotificationMessages, auditionRoleMessages, + auditionClientMessages, + auditionApplicantMessages, + auditionVoteMessages, settlementRatioMessages, adminCanMessages, adminChatBannerMessages, -- 2.49.1 From 6e8a88178c59eaa99d99ff735b24f42fc8034647 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 18:09:17 +0900 Subject: [PATCH 82/90] =?UTF-8?q?=EC=BA=94=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/can/CanController.kt | 6 +- .../sodalive/can/charge/ChargeController.kt | 18 +- .../sodalive/can/charge/ChargeService.kt | 101 +++++----- .../can/charge/event/ChargeEventService.kt | 35 +++- .../can/charge/temp/ChargeTempController.kt | 2 +- .../can/charge/temp/ChargeTempService.kt | 21 ++- .../can/coupon/CanCouponController.kt | 24 ++- .../can/coupon/CanCouponIssueService.kt | 12 +- .../sodalive/can/coupon/CanCouponService.kt | 26 ++- .../sodalive/can/payment/CanPaymentService.kt | 47 +++-- .../sodalive/i18n/SodaMessageSource.kt | 176 ++++++++++++++++++ 11 files changed, 358 insertions(+), 110 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt index e002aba4..6418cd14 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt @@ -27,7 +27,7 @@ class CanController(private val service: CanService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getCanStatus(member, container)) @@ -41,7 +41,7 @@ class CanController(private val service: CanService) { pageable: Pageable ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container)) @@ -55,7 +55,7 @@ class CanController(private val service: CanService) { pageable: Pageable ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt index 73948f3b..042da59f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeController.kt @@ -33,7 +33,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.payverseCharge(member, request)) @@ -45,7 +45,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val response = service.payverseVerify(memberId = member.id!!, verifyRequest) @@ -83,7 +83,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.charge(member, chargeRequest)) @@ -95,7 +95,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val response = service.verify(memberId = member.id!!, verifyRequest) @@ -109,7 +109,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val response = service.verifyHecto(memberId = member.id!!, verifyRequest) @@ -123,7 +123,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.appleCharge(member, chargeRequest)) @@ -135,7 +135,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } val response = service.appleVerify(memberId = member.id!!, verifyRequest) @@ -149,7 +149,7 @@ class ChargeController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } if (request.paymentGateway == PaymentGateway.GOOGLE_IAP) { @@ -174,7 +174,7 @@ class ChargeController( trackingCharge(member, response) ApiResponse.ok(Unit) } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt index c3610413..2c631b16 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/ChargeService.kt @@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.google.GooglePlayService +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.point.MemberPoint @@ -53,6 +55,8 @@ class ChargeService( private val applicationEventPublisher: ApplicationEventPublisher, private val googlePlayService: GooglePlayService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${bootpay.application-id}") private val bootpayApplicationId: String, @@ -174,10 +178,10 @@ class ChargeService( @Transactional fun chargeByCoupon(couponNumber: String, member: Member): String { val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) - ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") + ?: throw SodaException(messageKey = "can.coupon.invalid_number_contact") if (canCouponNumber.member != null) { - throw SodaException("이미 사용한 쿠폰번호 입니다.") + throw SodaException(messageKey = "can.coupon.already_used") } canCouponNumber.member = member @@ -186,7 +190,7 @@ class ChargeService( when (coupon.couponType) { CouponType.CAN -> { val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON) - couponCharge.title = "${coupon.can} 캔" + couponCharge.title = formatMessage("can.charge.title", coupon.can) couponCharge.member = member val payment = Payment( @@ -198,7 +202,7 @@ class ChargeService( chargeRepository.save(couponCharge) member.charge(0, coupon.can, "pg") - return "쿠폰 사용이 완료되었습니다.\n${coupon.can}캔이 지급되었습니다." + return formatMessage("can.coupon.use_complete", coupon.can) } CouponType.POINT -> { @@ -226,7 +230,7 @@ class ChargeService( ) ) - return "쿠폰 사용이 완료되었습니다.\n${coupon.can}포인트가 지급되었습니다." + return formatMessage("can.coupon.use_complete_point", coupon.can) } } } @@ -234,7 +238,7 @@ class ChargeService( @Transactional fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse { val can = canRepository.findByIdOrNull(request.canId) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "can.charge.invalid_request_restart") val requestCurrency = can.currency val isKrw = requestCurrency == "KRW" @@ -304,9 +308,9 @@ class ChargeService( @Transactional fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") val isKrw = charge.can?.currency == "KRW" val mid = if (isKrw) { @@ -322,7 +326,7 @@ class ChargeService( // 결제수단 확인 if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } // 결제 상태에 따른 분기 처리 @@ -339,10 +343,11 @@ class ChargeService( val response = okHttpClient.newCall(request).execute() if (!response.isSuccessful) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } - val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.") + val body = response.body?.string() + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java) val customerId = "${serverEnv}_user_${member.id!!}" @@ -380,10 +385,10 @@ class ChargeService( isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } catch (_: Exception) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } @@ -397,7 +402,7 @@ class ChargeService( } else -> { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } } @@ -405,7 +410,7 @@ class ChargeService( @Transactional fun charge(member: Member, request: ChargeRequest): ChargeResponse { val can = canRepository.findByIdOrNull(request.canId) - ?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "can.charge.invalid_request_restart") val charge = Charge(can.can, can.rewardCan) charge.title = can.title @@ -424,9 +429,9 @@ class ChargeService( @Transactional fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) @@ -457,22 +462,22 @@ class ChargeService( isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } catch (_: Exception) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } @Transactional fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey) @@ -507,13 +512,13 @@ class ChargeService( isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } catch (_: Exception) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } @@ -542,15 +547,17 @@ class ChargeService( @Transactional fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { // 검증로직 if (requestRealServerVerify(verifyRequest)) { charge.payment?.receiptId = verifyRequest.receiptString - charge.payment?.method = "애플(인 앱 결제)" + charge.payment?.method = messageSource + .getMessage("can.charge.payment_method.apple_iap", langContext.lang) + .orEmpty() charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "ios") @@ -567,10 +574,10 @@ class ChargeService( isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } @@ -594,7 +601,9 @@ class ChargeService( payment.locale = currencyCode payment.price = price payment.receiptId = purchaseToken - payment.method = "구글(인 앱 결제)" + payment.method = messageSource + .getMessage("can.charge.payment_method.google_iap", langContext.lang) + .orEmpty() charge.payment = payment chargeRepository.save(charge) @@ -610,9 +619,9 @@ class ChargeService( purchaseToken: String ): ChargeCompleteResponse { val charge = chargeRepository.findByIdOrNull(id = chargeId) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.status == PaymentStatus.REQUEST) { val orderId = verifyPurchase(purchaseToken, productId) @@ -634,10 +643,10 @@ class ChargeService( isFirstCharged = chargeRepository.isFirstCharged(memberId) ) } else { - throw SodaException("구매를 하지 못했습니다.\n고객센터로 문의해 주세요") + throw SodaException(messageKey = "can.charge.purchase_failed_contact") } } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } @@ -670,14 +679,14 @@ class ChargeService( } else -> { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } } else { - throw SodaException("결제를 완료하지 못했습니다.") + throw SodaException(messageKey = "can.charge.payment_incomplete") } } else { - throw SodaException("결제를 완료하지 못했습니다.") + throw SodaException(messageKey = "can.charge.payment_incomplete") } } @@ -701,23 +710,31 @@ class ChargeService( } else -> { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } } else { - throw SodaException("결제를 완료하지 못했습니다.") + throw SodaException(messageKey = "can.charge.payment_incomplete") } } else { - throw SodaException("결제를 완료하지 못했습니다.") + throw SodaException(messageKey = "can.charge.payment_incomplete") } } + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } + // Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환 private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? { val cardCodes = setOf( "041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381", "218", "071", "002", "089", "045", "050", "048", "090", "092" ) - return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null + if (schemeCode == null || !cardCodes.contains(schemeCode)) { + return null + } + return messageSource.getMessage("can.charge.payment_method.card", langContext.lang) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt index a37ebb1d..625377c1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/event/ChargeEventService.kt @@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.auth.AuthRepository @@ -26,15 +28,17 @@ class ChargeEventService( private val memberRepository: MemberRepository, private val chargeRepository: ChargeRepository, private val chargeEventRepository: ChargeEventRepository, - private val applicationEventPublisher: ApplicationEventPublisher + private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, + private val langContext: LangContext ) { @Transactional fun applyChargeEvent(chargeId: Long, memberId: Long) { val charge = chargeRepository.findByIdOrNull(chargeId) - ?: throw SodaException("이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.") + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.") + ?: throw SodaException(messageKey = "can.charge.event.not_applied_contact") if (member.auth != null) { val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di) @@ -79,7 +83,10 @@ class ChargeEventService( FcmEvent( type = FcmEventType.INDIVIDUAL, title = chargeEvent.title, - message = "$additionalCan 캔이 추가 지급되었습니다.", + message = formatMessage( + "can.charge.event.additional_can_paid", + additionalCan + ), recipients = listOf(member.id!!), isAuth = null ) @@ -94,14 +101,21 @@ class ChargeEventService( additionalCan = additionalCan, member = member, paymentGateway = charge.payment?.paymentGateway!!, - method = "첫 충전 이벤트" + method = messageSource + .getMessage("can.charge.event.first_title", langContext.lang) + .orEmpty() ) applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, - title = "첫 충전 이벤트", - message = "$additionalCan 캔이 추가 지급되었습니다.", + title = messageSource + .getMessage("can.charge.event.first_title", langContext.lang) + .orEmpty(), + message = formatMessage( + "can.charge.event.additional_can_paid", + additionalCan + ), recipients = listOf(member.id!!), isAuth = null ) @@ -110,7 +124,7 @@ class ChargeEventService( private fun applyEvent(additionalCan: Int, member: Member, paymentGateway: PaymentGateway, method: String) { val eventCharge = Charge(0, additionalCan, status = ChargeStatus.EVENT) - eventCharge.title = "$additionalCan 캔" + eventCharge.title = formatMessage("can.charge.title", additionalCan) eventCharge.member = member val payment = Payment( @@ -127,4 +141,9 @@ class ChargeEventService( else -> member.charge(0, additionalCan, "pg") } } + + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt index aafbc790..b1943d43 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempController.kt @@ -20,7 +20,7 @@ class ChargeTempController(private val service: ChargeTempService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) { - throw SodaException("로그인 정보를 확인해주세요.") + throw SodaException(messageKey = "common.error.bad_credentials") } ApiResponse.ok(service.charge(member, request)) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt index 3f2f1aa4..2eaa248a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/charge/temp/ChargeTempService.kt @@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.payment.PaymentStatus import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.extensions.moneyFormat +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import org.springframework.beans.factory.annotation.Value @@ -27,6 +29,8 @@ class ChargeTempService( private val memberRepository: MemberRepository, private val objectMapper: ObjectMapper, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${bootpay.hecto-application-id}") private val bootpayApplicationId: String, @@ -37,7 +41,7 @@ class ChargeTempService( @Transactional fun charge(member: Member, request: ChargeTempRequest): ChargeResponse { val charge = Charge(request.can, 0) - charge.title = "${request.can.moneyFormat()} 캔" + charge.title = formatMessage("can.charge.title", request.can.moneyFormat()) charge.member = member val payment = Payment(paymentGateway = request.paymentGateway) @@ -52,9 +56,9 @@ class ChargeTempService( @Transactional fun verify(user: User, verifyRequest: VerifyRequest) { val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong()) - ?: throw SodaException("결제정보에 오류가 있습니다.") + ?: throw SodaException(messageKey = "can.charge.invalid_payment_info") val member = memberRepository.findByEmail(user.username) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") if (charge.payment!!.paymentGateway == PaymentGateway.PG) { val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) @@ -72,13 +76,18 @@ class ChargeTempService( charge.payment?.status = PaymentStatus.COMPLETE member.charge(charge.chargeCan, charge.rewardCan, "pg") } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } catch (_: Exception) { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } else { - throw SodaException("결제정보에 오류가 있습니다.") + throw SodaException(messageKey = "can.charge.invalid_payment_info") } } + + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponController.kt index 628145ac..dfa4f37c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.can.coupon import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.core.io.InputStreamResource import org.springframework.data.domain.Pageable @@ -22,14 +24,18 @@ import java.nio.charset.StandardCharsets @RestController @RequestMapping("/can/coupon") -class CanCouponController(private val service: CanCouponService) { +class CanCouponController( + private val service: CanCouponService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext +) { @PostMapping @PreAuthorize("hasRole('ADMIN')") fun generateCoupon( @RequestBody request: GenerateCanCouponRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.generateCoupon(request)) } @@ -40,7 +46,7 @@ class CanCouponController(private val service: CanCouponService) { @RequestBody request: ModifyCanCouponRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.modifyCoupon(request)) } @@ -51,7 +57,7 @@ class CanCouponController(private val service: CanCouponService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCouponList(offset = pageable.offset, limit = pageable.pageSize.toLong())) } @@ -63,7 +69,7 @@ class CanCouponController(private val service: CanCouponService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCouponNumberList( @@ -80,9 +86,11 @@ class CanCouponController(private val service: CanCouponService) { @RequestParam couponId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") - val fileName = "쿠폰번호리스트.xlsx" + val fileName = messageSource + .getMessage("can.coupon.download_filename", langContext.lang) + .orEmpty() val encodedFileName = URLEncoder.encode( fileName, StandardCharsets.UTF_8.toString() @@ -107,7 +115,7 @@ class CanCouponController(private val service: CanCouponService) { @RequestBody request: UseCanCouponRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val completeMessage = service.useCanCoupon( couponNumber = request.couponNumber, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponIssueService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponIssueService.kt index 2af50b14..3492d8e9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponIssueService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponIssueService.kt @@ -12,7 +12,7 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR if (!isMultipleUse(canCouponNumber)) { val canCouponNumberList = couponNumberRepository.findByMemberId(memberId = memberId) if (canCouponNumberList.isNotEmpty()) { - throw SodaException("해당 쿠폰은 1회만 충전이 가능합니다.") + throw SodaException(messageKey = "can.coupon.single_use_only") } } @@ -21,10 +21,10 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR private fun checkCanCouponNumber(couponNumber: String): CanCouponNumber { val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber) - ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") + ?: throw SodaException(messageKey = "can.coupon.invalid_number_contact") if (canCouponNumber.member != null) { - throw SodaException("이미 사용한 쿠폰번호 입니다.") + throw SodaException(messageKey = "can.coupon.already_used") } return canCouponNumber @@ -34,17 +34,17 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR private fun validateCoupon(canCoupon: CanCoupon) { if (canCoupon.validity < LocalDateTime.now()) { - throw SodaException("유효기간이 경과된 쿠폰입니다.") + throw SodaException(messageKey = "can.coupon.expired") } if (!canCoupon.isActive) { - throw SodaException("이용이 불가능한 쿠폰입니다.") + throw SodaException(messageKey = "can.coupon.inactive") } } fun checkAnyChanges(request: ModifyCanCouponRequest) { if (request.isMultipleUse == null && request.isActive == null && request.validity == null) { - throw SodaException("변경사항이 없습니다.") + throw SodaException(messageKey = "can.coupon.no_changes") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt index ff882833..54f17365 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponService.kt @@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.aws.sqs.SqsEvent import kr.co.vividnext.sodalive.aws.sqs.SqsEventType import kr.co.vividnext.sodalive.can.charge.ChargeService import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.MemberRepository import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.context.ApplicationEventPublisher @@ -29,7 +31,9 @@ class CanCouponService( private val memberRepository: MemberRepository, private val objectMapper: ObjectMapper, - private val applicationEventPublisher: ApplicationEventPublisher + private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, + private val langContext: LangContext ) { fun generateCoupon(request: GenerateCanCouponRequest) { val message = objectMapper.writeValueAsString(request) @@ -41,7 +45,7 @@ class CanCouponService( issueService.checkAnyChanges(request) val canCoupon = repository.findByIdOrNull(id = request.couponId) - ?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.") + ?: throw SodaException(messageKey = "can.coupon.invalid_number_contact") if (request.validity != null) { val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") @@ -51,7 +55,7 @@ class CanCouponService( .toLocalDateTime() if (validity <= canCoupon.validity) { - throw SodaException("유효기간은 기존 유효기간 이후 날짜로 설정하실 수 있습니다.") + throw SodaException(messageKey = "can.coupon.validity_after_current") } canCoupon.validity = validity @@ -85,7 +89,11 @@ class CanCouponService( } fun downloadCouponNumberList(couponId: Long): ByteArrayInputStream { - val header = listOf("순번", "쿠폰번호", "사용여부") + val header = listOf( + messageSource.getMessage("can.coupon.download_header.index", langContext.lang).orEmpty(), + messageSource.getMessage("can.coupon.download_header.number", langContext.lang).orEmpty(), + messageSource.getMessage("can.coupon.download_header.used", langContext.lang).orEmpty() + ) val byteArrayOutputStream = ByteArrayOutputStream() val couponNumberList = couponNumberRepository.getAllCouponNumberList(couponId) @@ -104,9 +112,9 @@ class CanCouponService( couponNumberRow.createCell(1).setCellValue(insertHyphens(item.couponNumber)) couponNumberRow.createCell(2).setCellValue( if (item.isUsed) { - "O" + messageSource.getMessage("can.coupon.download_used_mark", langContext.lang).orEmpty() } else { - "X" + messageSource.getMessage("can.coupon.download_unused_mark", langContext.lang).orEmpty() } ) } @@ -114,7 +122,7 @@ class CanCouponService( workbook.write(byteArrayOutputStream) return ByteArrayInputStream(byteArrayOutputStream.toByteArray()) } catch (e: IOException) { - throw SodaException("다운로드를 하지 못했습니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "can.coupon.download_failed_retry") } finally { workbook.close() byteArrayOutputStream.close() @@ -123,9 +131,9 @@ class CanCouponService( fun useCanCoupon(couponNumber: String, memberId: Long): String { val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException("로그인 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "common.error.bad_credentials") - if (member.auth == null) throw SodaException("쿠폰은 본인인증을 하셔야 사용이 가능합니다.") + if (member.auth == null) throw SodaException(messageKey = "can.coupon.auth_required") issueService.validateAvailableUseCoupon(couponNumber, memberId) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index ef43b378..ffc8101b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository @@ -31,7 +33,9 @@ class CanPaymentService( private val memberRepository: MemberRepository, private val chargeRepository: ChargeRepository, private val useCanRepository: UseCanRepository, - private val useCanCalculateRepository: UseCanCalculateRepository + private val useCanCalculateRepository: UseCanCalculateRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext ) { @Transactional fun spendCan( @@ -49,7 +53,7 @@ class CanPaymentService( container: String ) { val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "can.payment.invalid_request_retry") val useRewardCan = spendRewardCan(member, needCan, container) val useChargeCan = if (needCan - useRewardCan.total > 0) { spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container) @@ -58,14 +62,14 @@ class CanPaymentService( } if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0) throw SodaException( - "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + - "캔이 부족합니다. 충전 후 이용해 주세요." + formatMessage("can.payment.insufficient_can", shortCan) ) } if (!useRewardCan.verify() || useChargeCan?.verify() == false) { - throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "can.payment.invalid_request_retry") } val useCan = UseCan( @@ -121,7 +125,7 @@ class CanPaymentService( useCan.chatRoomId = chatRoomId useCan.characterId = characterId } else { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } useCanRepository.save(useCan) @@ -306,20 +310,20 @@ class CanPaymentService( @Transactional fun refund(memberId: Long, roomId: Long) { val member = memberRepository.findByIdOrNull(memberId) - ?: throw SodaException("잘못된 예약정보 입니다.") + ?: throw SodaException(messageKey = "can.payment.invalid_reservation") val useCan = repository.getCanUsedForLiveRoomNotRefund( memberId = memberId, roomId = roomId, canUsage = CanUsage.LIVE - ) ?: throw SodaException("잘못된 예약정보 입니다.") + ) ?: throw SodaException(messageKey = "can.payment.invalid_reservation") useCan.isRefund = true val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!) useCanCalculates.forEach { it.status = UseCanCalculateStatus.REFUND val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE) - charge.title = "${it.can} 캔" + charge.title = formatMessage("can.charge.title", it.can) charge.useCan = useCan when (it.paymentGateway) { @@ -333,7 +337,9 @@ class CanPaymentService( status = PaymentStatus.COMPLETE, paymentGateway = it.paymentGateway ) - payment.method = "환불" + payment.method = messageSource + .getMessage("can.payment.method.refund", langContext.lang) + .orEmpty() charge.payment = payment chargeRepository.save(charge) @@ -348,7 +354,7 @@ class CanPaymentService( container: String ) { val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "can.payment.invalid_request_retry") val useRewardCan = spendRewardCan(member, needCan, container) val useChargeCan = if (needCan - useRewardCan.total > 0) { @@ -358,14 +364,14 @@ class CanPaymentService( } if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0) throw SodaException( - "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + - "캔이 부족합니다. 충전 후 이용해 주세요." + formatMessage("can.payment.insufficient_can", shortCan) ) } if (!useRewardCan.verify() || useChargeCan?.verify() == false) { - throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "can.payment.invalid_request_retry") } val useCan = UseCan( @@ -394,7 +400,7 @@ class CanPaymentService( container: String ) { val member = memberRepository.findByIdOrNull(id = memberId) - ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "can.payment.invalid_request_retry") val useRewardCan = spendRewardCan(member, needCan, container) val useChargeCan = if (needCan - useRewardCan.total > 0) { @@ -404,14 +410,14 @@ class CanPaymentService( } if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0) throw SodaException( - "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + - "캔이 부족합니다. 충전 후 이용해 주세요." + formatMessage("can.payment.insufficient_can", shortCan) ) } if (!useRewardCan.verify() || useChargeCan?.verify() == false) { - throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "can.payment.invalid_request_retry") } val useCan = UseCan( @@ -435,4 +441,9 @@ class CanPaymentService( setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) } + + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 04940ef5..3dd61bbc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -182,6 +182,178 @@ class SodaMessageSource { ) ) + private val canChargeMessages = mapOf( + "can.charge.invalid_payment_info" to mapOf( + Lang.KO to "결제정보에 오류가 있습니다.", + Lang.EN to "There is an error with the payment information.", + Lang.JA to "決済情報に誤りがあります。" + ), + "can.charge.invalid_request_restart" to mapOf( + Lang.KO to "잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease restart the app and try again.", + Lang.JA to "不正なリクエストです。\nアプリを終了して再度お試しください。" + ), + "can.charge.purchase_failed_contact" to mapOf( + Lang.KO to "구매를 하지 못했습니다.\n고객센터로 문의해 주세요", + Lang.EN to "Purchase could not be completed.\nPlease contact customer support.", + Lang.JA to "購入を完了できませんでした。\nカスタマーサポートへお問い合わせください。" + ), + "can.charge.payment_incomplete" to mapOf( + Lang.KO to "결제를 완료하지 못했습니다.", + Lang.EN to "Payment could not be completed.", + Lang.JA to "決済を完了できませんでした。" + ), + "can.charge.payment_method.apple_iap" to mapOf( + Lang.KO to "애플(인 앱 결제)", + Lang.EN to "Apple (In-App Purchase)", + Lang.JA to "Apple(アプリ内課金)" + ), + "can.charge.payment_method.google_iap" to mapOf( + Lang.KO to "구글(인 앱 결제)", + Lang.EN to "Google (In-App Purchase)", + Lang.JA to "Google(アプリ内課金)" + ), + "can.charge.payment_method.card" to mapOf( + Lang.KO to "카드", + Lang.EN to "Card", + Lang.JA to "カード" + ), + "can.charge.title" to mapOf( + Lang.KO to "%s 캔", + Lang.EN to "%s cans", + Lang.JA to "%s缶" + ) + ) + + private val canChargeEventMessages = mapOf( + "can.charge.event.not_applied_contact" to mapOf( + Lang.KO to "이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.", + Lang.EN to "The event was not applied.\nPlease contact customer support.", + Lang.JA to "イベントが適用されていません。\nカスタマーサポートへお問い合わせください。" + ), + "can.charge.event.additional_can_paid" to mapOf( + Lang.KO to "%s 캔이 추가 지급되었습니다.", + Lang.EN to "%s cans have been added.", + Lang.JA to "%s缶が追加で支給されました。" + ), + "can.charge.event.first_title" to mapOf( + Lang.KO to "첫 충전 이벤트", + Lang.EN to "First Recharge Event", + Lang.JA to "初回チャージイベント" + ) + ) + + private val canCouponMessages = mapOf( + "can.coupon.invalid_number_contact" to mapOf( + Lang.KO to "잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.", + Lang.EN to "Invalid coupon number.\nPlease contact customer support.", + Lang.JA to "無効なクーポン番号です。\nカスタマーサポートへお問い合わせください。" + ), + "can.coupon.already_used" to mapOf( + Lang.KO to "이미 사용한 쿠폰번호 입니다.", + Lang.EN to "This coupon number has already been used.", + Lang.JA to "すでに使用されたクーポン番号です。" + ), + "can.coupon.use_complete" to mapOf( + Lang.KO to "쿠폰 사용이 완료되었습니다.\n%s캔이 지급되었습니다.", + Lang.EN to "Coupon redeemed successfully.\n%s cans have been granted.", + Lang.JA to "クーポンの使用が完了しました。\n%s缶が支給されました。" + ), + "can.coupon.use_complete_point" to mapOf( + Lang.KO to "쿠폰 사용이 완료되었습니다.\n%s포인트가 지급되었습니다.", + Lang.EN to "Coupon redeemed successfully.\n%s points have been granted.", + Lang.JA to "クーポンの使用が完了しました。\n%sポイントが付与されました。" + ), + "can.coupon.single_use_only" to mapOf( + Lang.KO to "해당 쿠폰은 1회만 충전이 가능합니다.", + Lang.EN to "This coupon can be used only once for charging.", + Lang.JA to "このクーポンは1回のみチャージに使用できます。" + ), + "can.coupon.expired" to mapOf( + Lang.KO to "유효기간이 경과된 쿠폰입니다.", + Lang.EN to "This coupon has expired.", + Lang.JA to "有効期限が切れたクーポンです。" + ), + "can.coupon.inactive" to mapOf( + Lang.KO to "이용이 불가능한 쿠폰입니다.", + Lang.EN to "This coupon is not available.", + Lang.JA to "利用できないクーポンです。" + ), + "can.coupon.no_changes" to mapOf( + Lang.KO to "변경사항이 없습니다.", + Lang.EN to "No changes found.", + Lang.JA to "変更事項がありません。" + ), + "can.coupon.validity_after_current" to mapOf( + Lang.KO to "유효기간은 기존 유효기간 이후 날짜로 설정하실 수 있습니다.", + Lang.EN to "Validity must be set after the current expiration date.", + Lang.JA to "有効期限は現在の有効期限以降に設定できます。" + ), + "can.coupon.auth_required" to mapOf( + Lang.KO to "쿠폰은 본인인증을 하셔야 사용이 가능합니다.", + Lang.EN to "You must verify your identity to use coupons.", + Lang.JA to "クーポンの使用には本人認証が必要です。" + ), + "can.coupon.download_failed_retry" to mapOf( + Lang.KO to "다운로드를 하지 못했습니다.\n다시 시도해 주세요.", + Lang.EN to "Download failed.\nPlease try again.", + Lang.JA to "ダウンロードできませんでした。\nもう一度お試しください。" + ), + "can.coupon.download_filename" to mapOf( + Lang.KO to "쿠폰번호리스트.xlsx", + Lang.EN to "coupon_number_list.xlsx", + Lang.JA to "クーポン番号リスト.xlsx" + ), + "can.coupon.download_header.index" to mapOf( + Lang.KO to "순번", + Lang.EN to "No.", + Lang.JA to "番号" + ), + "can.coupon.download_header.number" to mapOf( + Lang.KO to "쿠폰번호", + Lang.EN to "Coupon Number", + Lang.JA to "クーポン番号" + ), + "can.coupon.download_header.used" to mapOf( + Lang.KO to "사용여부", + Lang.EN to "Used", + Lang.JA to "使用有無" + ), + "can.coupon.download_used_mark" to mapOf( + Lang.KO to "O", + Lang.EN to "O", + Lang.JA to "O" + ), + "can.coupon.download_unused_mark" to mapOf( + Lang.KO to "X", + Lang.EN to "X", + Lang.JA to "X" + ) + ) + + private val canPaymentMessages = mapOf( + "can.payment.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request.\nPlease try again.", + Lang.JA to "不正なリクエストです。\nもう一度お試しください。" + ), + "can.payment.invalid_reservation" to mapOf( + Lang.KO to "잘못된 예약정보 입니다.", + Lang.EN to "Invalid reservation information.", + Lang.JA to "無効な予約情報です。" + ), + "can.payment.method.refund" to mapOf( + Lang.KO to "환불", + Lang.EN to "Refund", + Lang.JA to "返金" + ), + "can.payment.insufficient_can" to mapOf( + Lang.KO to "%s 캔이 부족합니다. 충전 후 이용해 주세요.", + Lang.EN to "You are short of %s cans. Please recharge and try again.", + Lang.JA to "%s缶が不足しています。チャージしてからご利用ください。" + ) + ) + private val adminChatBannerMessages = mapOf( "admin.chat.banner.image_save_failed" to mapOf( Lang.KO to "이미지 저장에 실패했습니다.", @@ -1546,6 +1718,10 @@ class SodaMessageSource { auditionVoteMessages, settlementRatioMessages, adminCanMessages, + canChargeMessages, + canChargeEventMessages, + canCouponMessages, + canPaymentMessages, adminChatBannerMessages, adminChatCalculateMessages, adminChatCharacterMessages, -- 2.49.1 From 9d619450ef3ff47ed7f5e3637f7ab9873342879b Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 18:38:54 +0900 Subject: [PATCH 83/90] =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/CharacterCommentController.kt | 38 +-- .../comment/CharacterCommentService.kt | 45 ++-- .../controller/ChatCharacterController.kt | 6 +- .../image/CharacterImageController.kt | 16 +- .../character/image/CharacterImageService.kt | 20 +- .../service/ChatCharacterBannerService.kt | 20 +- .../character/service/ChatCharacterService.kt | 2 +- .../controller/OriginalWorkController.kt | 4 +- .../service/OriginalWorkQueryService.kt | 4 +- .../chat/quota/ChatQuotaController.kt | 10 +- .../quota/room/ChatRoomQuotaController.kt | 28 +- .../chat/quota/room/ChatRoomQuotaService.kt | 6 +- .../room/controller/ChatRoomController.kt | 32 +-- .../chat/room/service/ChatRoomService.kt | 81 +++--- .../sodalive/i18n/SodaMessageSource.kt | 252 ++++++++++++++++++ 15 files changed, 420 insertions(+), 144 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt index e9fcc649..9a903a9f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.springframework.beans.factory.annotation.Value import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/chat/character") class CharacterCommentController( private val service: CharacterCommentService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -28,9 +32,9 @@ class CharacterCommentController( @RequestBody request: CreateCharacterCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val id = service.addComment(characterId, member, request.comment) ApiResponse.ok(id) @@ -43,9 +47,9 @@ class CharacterCommentController( @RequestBody request: CreateCharacterCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode) ApiResponse.ok(id) @@ -58,8 +62,8 @@ class CharacterCommentController( @RequestParam(required = false) cursor: Long?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val data = service.listComments(imageHost, characterId, cursor, limit) ApiResponse.ok(data) @@ -73,8 +77,8 @@ class CharacterCommentController( @RequestParam(required = false) cursor: Long?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") // characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨 val data = service.getReplies(imageHost, commentId, cursor, limit) @@ -87,10 +91,11 @@ class CharacterCommentController( @PathVariable commentId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") service.deleteComment(characterId, commentId, member) - ApiResponse.ok(true, "댓글이 삭제되었습니다.") + val message = messageSource.getMessage("chat.character.comment.deleted", langContext.lang) + ApiResponse.ok(true, message) } @PostMapping("/{characterId}/comments/{commentId}/reports") @@ -100,9 +105,10 @@ class CharacterCommentController( @RequestBody request: ReportCharacterCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") service.reportComment(characterId, commentId, member, request.content) - ApiResponse.ok(true, "신고가 접수되었습니다.") + val message = messageSource.getMessage("chat.character.comment.reported", langContext.lang) + ApiResponse.ok(true, message) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 1be90668..8d5cd826 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -36,7 +36,7 @@ class CharacterCommentService( entity: CharacterComment, replyCountOverride: Int? = null ): CharacterCommentResponse { - val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") + val member = entity.member ?: throw SodaException(messageKey = "chat.character.comment.invalid") return CharacterCommentResponse( commentId = entity.id!!, memberId = member.id!!, @@ -50,7 +50,7 @@ class CharacterCommentService( } private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse { - val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") + val member = entity.member ?: throw SodaException(messageKey = "chat.character.comment.invalid") return CharacterReplyResponse( replyId = entity.id!!, memberId = member.id!!, @@ -64,9 +64,10 @@ class CharacterCommentService( @Transactional fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long { - val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } - if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") - if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + val character = chatCharacterRepository.findById(characterId) + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } + if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive") + if (text.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character @@ -95,12 +96,14 @@ class CharacterCommentService( text: String, languageCode: String? = null ): Long { - val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } - if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") - val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } - if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") - if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.") - if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + val character = chatCharacterRepository.findById(characterId) + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } + if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive") + val parent = commentRepository.findById(parentCommentId) + .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") } + if (parent.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request") + if (!parent.isActive) throw SodaException(messageKey = "chat.character.comment.inactive") + if (text.isBlank()) throw SodaException(messageKey = "chat.character.comment.required") val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character @@ -162,9 +165,9 @@ class CharacterCommentService( limit: Int = 20 ): CharacterCommentRepliesResponse { val original = commentRepository.findById(commentId).orElseThrow { - SodaException("댓글을 찾을 수 없습니다.") + SodaException(messageKey = "chat.character.comment.not_found") } - if (!original.isActive) throw SodaException("비활성화된 댓글입니다.") + if (!original.isActive) throw SodaException(messageKey = "chat.character.comment.inactive") val pageable = PageRequest.of(0, limit) val replies = if (cursor == null) { @@ -207,20 +210,22 @@ class CharacterCommentService( @Transactional fun deleteComment(characterId: Long, commentId: Long, member: Member) { - val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } - if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") + val comment = commentRepository.findById(commentId) + .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") } + if (comment.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request") if (!comment.isActive) return - val ownerId = comment.member?.id ?: throw SodaException("유효하지 않은 댓글입니다.") - if (ownerId != member.id) throw SodaException("삭제 권한이 없습니다.") + val ownerId = comment.member?.id ?: throw SodaException(messageKey = "chat.character.comment.invalid") + if (ownerId != member.id) throw SodaException(messageKey = "chat.character.comment.delete_forbidden") comment.isActive = false commentRepository.save(comment) } @Transactional fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) { - val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } - if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") - if (content.isBlank()) throw SodaException("신고 내용을 입력해주세요.") + val comment = commentRepository.findById(commentId) + .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") } + if (comment.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request") + if (content.isBlank()) throw SodaException(messageKey = "chat.character.comment.report_content_required") val report = CharacterCommentReport(content = content) report.comment = comment diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 63ae72be..afa2bb37 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -155,12 +155,12 @@ class ChatCharacterController( @PathVariable characterId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") // 캐릭터 상세 정보 조회 val character = service.getCharacterDetail(characterId) - ?: throw SodaException("캐릭터를 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.character.not_found") // 태그 가공: # prefix 규칙 적용 후 공백으로 연결 val tags = character.tagMappings diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index 8744e26d..7e9d5899 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -36,8 +36,8 @@ class CharacterImageController( @RequestParam(required = false, defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val pageSize = if (size <= 0) 20 else minOf(size, 20) @@ -124,8 +124,8 @@ class CharacterImageController( @RequestParam(required = false, defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val pageSize = if (size <= 0) 20 else minOf(size, 20) val expiration = 5L * 60L * 1000L // 5분 @@ -198,18 +198,18 @@ class CharacterImageController( @RequestBody req: CharacterImagePurchaseRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val image = imageService.getById(req.imageId) - if (!image.isActive) throw SodaException("비활성화된 이미지입니다.") + if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive") val isOwned = (image.imagePriceCan == 0L) || imageService.isOwnedImageByMember(image.id!!, member.id!!) if (!isOwned) { val needCan = image.imagePriceCan.toInt() - if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.") + if (needCan <= 0) throw SodaException(messageKey = "chat.purchase.invalid_price") canPaymentService.spendCanForCharacterImage( memberId = member.id!!, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index b0bbe98f..596da874 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -64,11 +64,11 @@ class CharacterImageService( } fun getById(id: Long): CharacterImage = - imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } + imageRepository.findById(id).orElseThrow { SodaException(messageKey = "chat.character.image.not_found") } fun getCharacterImagePath(characterId: Long): String? { val character = characterRepository.findById(characterId) - .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } return character.imagePath } @@ -94,11 +94,13 @@ class CharacterImageService( triggers: List ): CharacterImage { val character = characterRepository.findById(characterId) - .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } - if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.") + if (imagePriceCan < 0 || messagePriceCan < 0) { + throw SodaException(messageKey = "chat.character.image.min_price") + } - if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") + if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive_image_register") val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 val entity = CharacterImage( @@ -122,7 +124,7 @@ class CharacterImageService( @Transactional fun updateTriggers(imageId: Long, triggers: List): CharacterImage { val image = getById(imageId) - if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId") + if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive_update") applyTriggers(image, triggers) return image } @@ -159,8 +161,10 @@ class CharacterImageService( val updated = mutableListOf() ids.forEachIndexed { idx, id -> val img = getById(id) - if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id") - if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id") + if (img.chatCharacter.id != characterId) { + throw SodaException(messageKey = "chat.character.image.other_character_included") + } + if (!img.isActive) throw SodaException(messageKey = "chat.character.image.inactive_order_change") img.sortOrder = idx + 1 updated.add(img) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index 1eeaadbb..6321c28d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -26,7 +26,7 @@ class ChatCharacterBannerService( */ fun getBannerById(bannerId: Long): ChatCharacterBanner { return bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") } } /** @@ -39,10 +39,10 @@ class ChatCharacterBannerService( @Transactional fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { val character = characterRepository.findById(characterId) - .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } if (!character.isActive) { - throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId") + throw SodaException(messageKey = "chat.character.inactive_banner_register") } // 정렬 순서가 지정되지 않은 경우 가장 마지막 순서로 설정 @@ -68,10 +68,10 @@ class ChatCharacterBannerService( @Transactional fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner { val banner = bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") } if (!banner.isActive) { - throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") + throw SodaException(messageKey = "chat.character.banner.inactive_update") } // 이미지 경로 변경 @@ -82,10 +82,10 @@ class ChatCharacterBannerService( // 캐릭터 변경 if (characterId != null) { val character = characterRepository.findById(characterId) - .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + .orElseThrow { SodaException(messageKey = "chat.character.not_found") } if (!character.isActive) { - throw SodaException("비활성화된 캐릭터로는 변경할 수 없습니다: $characterId") + throw SodaException(messageKey = "chat.character.inactive_banner_change") } banner.chatCharacter = character @@ -100,7 +100,7 @@ class ChatCharacterBannerService( @Transactional fun deleteBanner(bannerId: Long) { val banner = bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") } banner.isActive = false bannerRepository.save(banner) @@ -119,10 +119,10 @@ class ChatCharacterBannerService( for (index in ids.indices) { val banner = bannerRepository.findById(ids[index]) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } + .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") } if (!banner.isActive) { - throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") + throw SodaException(messageKey = "chat.character.banner.inactive_update") } banner.sortOrder = index + 1 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index faec7b04..60974827 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -702,7 +702,7 @@ class ChatCharacterService( ): ChatCharacter { // 캐릭터 조회 val chatCharacter = findById(request.id) - ?: throw kr.co.vividnext.sodalive.common.SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") + ?: throw kr.co.vividnext.sodalive.common.SodaException(messageKey = "chat.character.not_found") // isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다. if (request.isActive != null && !request.isActive) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 0cfbdef2..ce5232b2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -126,8 +126,8 @@ class OriginalWorkController( @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val ow = queryService.getOriginalWork(id) val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt index c32f3d3d..998cada9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -44,7 +44,7 @@ class OriginalWorkQueryService( @Transactional(readOnly = true) fun getOriginalWork(id: Long): OriginalWork { return originalWorkRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "chat.original.not_found") } } /** @@ -54,7 +54,7 @@ class OriginalWorkQueryService( fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page { // 원작 존재 및 소프트 삭제 여부 확인 originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + .orElseThrow { SodaException(messageKey = "chat.original.not_found") } val safePage = if (page < 0) 0 else page val safeSize = when { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index c82a2810..01bc064d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -32,8 +32,8 @@ class ChatQuotaController( fun getMyQuota( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ): ApiResponse = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val s = chatQuotaService.getStatus(member.id!!) ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) @@ -44,9 +44,9 @@ class ChatQuotaController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @RequestBody request: ChatQuotaPurchaseRequest ): ApiResponse = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - if (request.container.isBlank()) throw SodaException("container를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (request.container.isBlank()) throw SodaException(messageKey = "chat.quota.container_required") // 30캔 차감 처리 (결제 기록 남김) canPaymentService.spendCan( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt index 0fed1d01..c1d8f543 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -52,27 +52,27 @@ class ChatRoomQuotaController( @PathVariable chatRoomId: Long, @RequestBody req: PurchaseRoomQuotaRequest ): ApiResponse = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - if (req.container.isBlank()) throw SodaException("잘못된 접근입니다") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") + if (req.container.isBlank()) throw SodaException(messageKey = "chat.room.quota.invalid_access") val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") // 내 참여 여부 확인 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.quota.invalid_access") // 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조) val characterParticipant = participantRepository .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") val character = characterParticipant.character - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") val characterId = character.id - ?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.") + ?: throw SodaException(messageKey = "chat.room.quota.character_required") // 서비스에서 결제 포함하여 처리 val status = chatRoomQuotaService.purchase( @@ -98,20 +98,20 @@ class ChatRoomQuotaController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @PathVariable chatRoomId: Long ): ApiResponse = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") // 내 참여 여부 확인 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.quota.invalid_access") // 캐릭터 확인 val characterParticipant = participantRepository .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") val character = characterParticipant.character - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room") // 글로벌 Lazy refill val globalStatus = chatQuotaService.getStatus(member.id!!) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt index cb9021d6..b6ba430d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -75,7 +75,7 @@ class ChatRoomQuotaService( val now = Instant.now() val nowMillis = now.toEpochMilli() val quota = repo.findForUpdate(memberId, chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") // 충전 시간이 지났다면 무료 10으로 리셋하고 next=null if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { @@ -98,7 +98,7 @@ class ChatRoomQuotaService( val globalFree = globalFreeProvider() if (globalFree <= 0) { // 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가 - throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.") + throw SodaException(messageKey = "chat.room.quota.global_free_exhausted") } if (quota.remainingFree <= 0) { // 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가 @@ -107,7 +107,7 @@ class ChatRoomQuotaService( quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli() } - throw SodaException("무료 채팅이 모두 소진되었습니다.") + throw SodaException(messageKey = "chat.room.quota.room_free_exhausted") } // 둘 다 가능 → 차감 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 7434207b..d2b80fbb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -42,8 +42,8 @@ class ChatRoomController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @RequestBody request: CreateChatRoomRequest ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.createOrGetChatRoom(member, request.characterId) ApiResponse.ok(response) @@ -77,8 +77,8 @@ class ChatRoomController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @PathVariable chatRoomId: Long ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) ApiResponse.ok(isActive) @@ -95,8 +95,8 @@ class ChatRoomController( @PathVariable chatRoomId: Long, @RequestParam(required = false) characterImageId: Long? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId) ApiResponse.ok(response) @@ -114,8 +114,8 @@ class ChatRoomController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @PathVariable chatRoomId: Long ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") chatRoomService.leaveChatRoom(member, chatRoomId) ApiResponse.ok(true) @@ -134,8 +134,8 @@ class ChatRoomController( @RequestParam(defaultValue = "20") limit: Int, @RequestParam(required = false) cursor: Long? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit) ApiResponse.ok(response) @@ -153,8 +153,8 @@ class ChatRoomController( @PathVariable chatRoomId: Long, @RequestBody request: SendChatMessageRequest ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") if (request.message.isBlank()) { ApiResponse.error() @@ -176,8 +176,8 @@ class ChatRoomController( @PathVariable messageId: Long, @RequestBody request: ChatMessagePurchaseRequest ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) ApiResponse.ok(result) @@ -195,8 +195,8 @@ class ChatRoomController( @PathVariable chatRoomId: Long, @RequestBody request: ChatRoomResetRequest ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required") val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container) ApiResponse.ok(response) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 3a47432c..4219c26c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -29,6 +29,7 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -54,6 +55,7 @@ class ChatRoomService( private val characterService: ChatCharacterService, private val characterImageService: CharacterImageService, private val langContext: LangContext, + private val messageSource: SodaMessageSource, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, @@ -77,19 +79,19 @@ class ChatRoomService( @Transactional fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") // 참여 여부 검증 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.invalid_access") val message = messageRepository.findById(messageId).orElseThrow { - SodaException("메시지를 찾을 수 없습니다.") + SodaException(messageKey = "chat.message.not_found") } - if (!message.isActive) throw SodaException("비활성화된 메시지입니다.") - if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다") + if (!message.isActive) throw SodaException(messageKey = "chat.message.inactive") + if (message.chatRoom.id != room.id) throw SodaException(messageKey = "chat.room.invalid_access") - val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.") - if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.") + val price = message.price ?: throw SodaException(messageKey = "chat.message.not_purchasable") + if (price <= 0) throw SodaException(messageKey = "chat.purchase.invalid_price") // 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환 if (message.messageType == ChatMessageType.IMAGE) { @@ -124,7 +126,7 @@ class ChatRoomService( fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse { // 1. 캐릭터 조회 val character = characterService.findById(characterId) - ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") + ?: throw SodaException(messageKey = "chat.room.character_not_found") // 2. 이미 참여 중인 채팅방이 있는지 확인 val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character) @@ -225,21 +227,21 @@ class ChatRoomService( // success가 false이면 throw if (!apiResponse.success) { - throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "chat.room.create_failed_retry") } // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 - val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + val data = apiResponse.data ?: throw SodaException(messageKey = "chat.room.create_failed_retry") if (data.userId != userId && data.character.id != characterUUID && data.status != "active") { - throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "chat.room.create_failed_retry") } // 세션 ID 반환 return data.sessionId } catch (e: Exception) { log.error(e.message) - throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "chat.room.create_failed_retry") } } @@ -264,7 +266,7 @@ class ChatRoomService( } } else { if (latest?.message.isNullOrBlank() && latest?.characterImage != null) { - "[이미지]" + messageSource.getMessage("chat.room.last_message_image", langContext.lang).orEmpty() } else { "" } @@ -304,11 +306,19 @@ class ChatRoomService( val now = LocalDateTime.now() val duration = Duration.between(time, now) val seconds = duration.seconds - if (seconds <= 60) return "방금" + if (seconds <= 60) { + return messageSource.getMessage("chat.room.time.just_now", langContext.lang).orEmpty() + } val minutes = duration.toMinutes() - if (minutes < 60) return "${minutes}분 전" + if (minutes < 60) { + val template = messageSource.getMessage("chat.room.time.minutes_ago", langContext.lang).orEmpty() + return String.format(template, minutes) + } val hours = duration.toHours() - if (hours < 24) return "${hours}시간 전" + if (hours < 24) { + val template = messageSource.getMessage("chat.room.time.hours_ago", langContext.lang).orEmpty() + return String.format(template, hours) + } // 그 외: 날짜 (yyyy-MM-dd) return time.toLocalDate().toString() } @@ -510,23 +520,23 @@ class ChatRoomService( // success가 false이면 throw if (!apiResponse.success) { - throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "chat.error.retry") } val status = apiResponse.data?.status return status == "active" } catch (e: Exception) { e.printStackTrace() - throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + throw SodaException(messageKey = "chat.error.retry") } } @Transactional fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.invalid_access") // 1) 나가기 처리 participant.isActive = false @@ -589,10 +599,9 @@ class ChatRoomService( } } // 최종 실패 처리 - val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요." if (throwOnFailure) { log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts) - throw SodaException(message) + throw SodaException(messageKey = "chat.room.session_end_failed") } else { log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) } @@ -601,9 +610,9 @@ class ChatRoomService( @Transactional(readOnly = true) fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.invalid_access") val pageable = PageRequest.of(0, limit) val fetched = if (cursor != null) { @@ -636,18 +645,18 @@ class ChatRoomService( fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { // 1) 방 존재 확인 val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") // 2) 참여 여부 확인 (USER) val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.invalid_access") // 3) 캐릭터 참여자 조회 val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( room, ParticipantType.CHARACTER - ) ?: throw SodaException("잘못된 접근입니다") + ) ?: throw SodaException(messageKey = "chat.room.invalid_access") val character = characterParticipant.character - ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "chat.error.retry") // 4) 외부 API 호출 준비 val userId = generateUserId(member.id!!) @@ -833,7 +842,7 @@ class ChatRoomService( } } log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts) - throw SodaException("메시지 전송을 실패했습니다.") + throw SodaException(messageKey = "chat.message.send_failed") } private fun callExternalApiForChatSend( @@ -875,12 +884,12 @@ class ChatRoomService( ) if (!apiResponse.success) { - throw SodaException("메시지 전송을 실패했습니다.") + throw SodaException(messageKey = "chat.message.send_failed") } - val data = apiResponse.data ?: throw SodaException("메시지 전송을 실패했습니다.") + val data = apiResponse.data ?: throw SodaException(messageKey = "chat.message.send_failed") val characterContent = data.characterResponse.content if (characterContent.isBlank()) { - throw SodaException("메시지 전송을 실패했습니다.") + throw SodaException(messageKey = "chat.message.send_failed") } return characterContent } @@ -903,16 +912,16 @@ class ChatRoomService( fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse { // 0) 방 존재 및 내 참여 여부 확인 val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") + ?: throw SodaException(messageKey = "chat.error.room_not_found") participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + ?: throw SodaException(messageKey = "chat.room.invalid_access") // 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인) val characterParticipant = participantRepository .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.not_ai_room") val character = characterParticipant.character - ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + ?: throw SodaException(messageKey = "chat.room.not_ai_room") // 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용) canPaymentService.spendCan( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 3dd61bbc..16624887 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -20,6 +20,11 @@ class SodaMessageSource { Lang.EN to "Please check your login information.", Lang.JA to "ログイン情報を確認してください。" ), + "common.error.adult_verification_required" to mapOf( + Lang.KO to "본인인증을 하셔야 합니다.", + Lang.EN to "Identity verification is required.", + Lang.JA to "本人認証が必要です。" + ), "common.error.max_upload_size" to mapOf( Lang.KO to "파일용량은 최대 1024MB까지 저장할 수 있습니다.", Lang.EN to "The file size can be saved up to 1024MB.", @@ -1662,6 +1667,245 @@ class SodaMessageSource { ) ) + private val chatCharacterCommentMessages = mapOf( + "chat.character.comment.required" to mapOf( + Lang.KO to "댓글 내용을 입력해주세요.", + Lang.EN to "Please enter a comment.", + Lang.JA to "コメント内容を入力してください。" + ), + "chat.character.comment.deleted" to mapOf( + Lang.KO to "댓글이 삭제되었습니다.", + Lang.EN to "The comment has been deleted.", + Lang.JA to "コメントが削除されました。" + ), + "chat.character.comment.reported" to mapOf( + Lang.KO to "신고가 접수되었습니다.", + Lang.EN to "Your report has been received.", + Lang.JA to "通報が受け付けられました。" + ), + "chat.character.comment.invalid" to mapOf( + Lang.KO to "유효하지 않은 댓글입니다.", + Lang.EN to "Invalid comment.", + Lang.JA to "無効なコメントです。" + ), + "chat.character.comment.not_found" to mapOf( + Lang.KO to "댓글을 찾을 수 없습니다.", + Lang.EN to "Comment not found.", + Lang.JA to "コメントが見つかりません。" + ), + "chat.character.comment.inactive" to mapOf( + Lang.KO to "비활성화된 댓글입니다.", + Lang.EN to "This comment is inactive.", + Lang.JA to "無効化されたコメントです。" + ), + "chat.character.comment.delete_forbidden" to mapOf( + Lang.KO to "삭제 권한이 없습니다.", + Lang.EN to "You do not have permission to delete.", + Lang.JA to "削除権限がありません。" + ), + "chat.character.comment.report_content_required" to mapOf( + Lang.KO to "신고 내용을 입력해주세요.", + Lang.EN to "Please enter a report message.", + Lang.JA to "通報内容を入力してください。" + ) + ) + + private val chatCharacterMessages = mapOf( + "chat.character.not_found" to mapOf( + Lang.KO to "캐릭터를 찾을 수 없습니다.", + Lang.EN to "Character not found.", + Lang.JA to "キャラクターが見つかりません。" + ), + "chat.character.inactive" to mapOf( + Lang.KO to "비활성화된 캐릭터입니다.", + Lang.EN to "This character is inactive.", + Lang.JA to "無効化されたキャラクターです。" + ), + "chat.character.inactive_image_register" to mapOf( + Lang.KO to "비활성화된 캐릭터에는 이미지를 등록할 수 없습니다.", + Lang.EN to "Images cannot be registered for an inactive character.", + Lang.JA to "無効化されたキャラクターには画像を登録できません。" + ), + "chat.character.inactive_banner_register" to mapOf( + Lang.KO to "비활성화된 캐릭터에는 배너를 등록할 수 없습니다.", + Lang.EN to "Banners cannot be registered for an inactive character.", + Lang.JA to "無効化されたキャラクターにはバナーを登録できません。" + ), + "chat.character.inactive_banner_change" to mapOf( + Lang.KO to "비활성화된 캐릭터로는 변경할 수 없습니다.", + Lang.EN to "You cannot change to an inactive character.", + Lang.JA to "無効化されたキャラクターには変更できません。" + ) + ) + + private val chatCharacterImageMessages = mapOf( + "chat.character.image.not_found" to mapOf( + Lang.KO to "캐릭터 이미지를 찾을 수 없습니다.", + Lang.EN to "Character image not found.", + Lang.JA to "キャラクター画像が見つかりません。" + ), + "chat.character.image.inactive" to mapOf( + Lang.KO to "비활성화된 이미지입니다.", + Lang.EN to "This image is inactive.", + Lang.JA to "無効化された画像です。" + ), + "chat.character.image.min_price" to mapOf( + Lang.KO to "가격은 0 can 이상이어야 합니다.", + Lang.EN to "Price must be at least 0 can.", + Lang.JA to "価格は0can以上である必要があります。" + ), + "chat.character.image.inactive_update" to mapOf( + Lang.KO to "비활성화된 이미지는 수정할 수 없습니다.", + Lang.EN to "Inactive images cannot be updated.", + Lang.JA to "無効化された画像は修正できません。" + ), + "chat.character.image.other_character_included" to mapOf( + Lang.KO to "다른 캐릭터의 이미지가 포함되어 있습니다.", + Lang.EN to "Images from another character are included.", + Lang.JA to "別のキャラクターの画像が含まれています。" + ), + "chat.character.image.inactive_order_change" to mapOf( + Lang.KO to "비활성화된 이미지는 순서를 변경할 수 없습니다.", + Lang.EN to "Inactive images cannot change order.", + Lang.JA to "無効化された画像の順序は変更できません。" + ) + ) + + private val chatCharacterBannerMessages = mapOf( + "chat.character.banner.not_found" to mapOf( + Lang.KO to "배너를 찾을 수 없습니다.", + Lang.EN to "Banner not found.", + Lang.JA to "バナーが見つかりません。" + ), + "chat.character.banner.inactive_update" to mapOf( + Lang.KO to "비활성화된 배너는 수정할 수 없습니다.", + Lang.EN to "Inactive banners cannot be updated.", + Lang.JA to "無効化されたバナーは修正できません。" + ) + ) + + private val chatOriginalWorkMessages = mapOf( + "chat.original.not_found" to mapOf( + Lang.KO to "해당 원작을 찾을 수 없습니다.", + Lang.EN to "Original work not found.", + Lang.JA to "該当する原作が見つかりません。" + ) + ) + + private val chatQuotaMessages = mapOf( + "chat.quota.container_required" to mapOf( + Lang.KO to "container를 확인해주세요.", + Lang.EN to "Please check the container.", + Lang.JA to "containerを確認してください。" + ) + ) + + private val chatRoomQuotaMessages = mapOf( + "chat.room.quota.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ), + "chat.room.quota.not_ai_room" to mapOf( + Lang.KO to "AI 캐릭터 채팅방이 아닙니다.", + Lang.EN to "This is not an AI character chat room.", + Lang.JA to "AIキャラクターのチャットルームではありません。" + ), + "chat.room.quota.character_required" to mapOf( + Lang.KO to "잘못된 요청입니다. 캐릭터 정보를 확인해주세요.", + Lang.EN to "Invalid request. Please check the character information.", + Lang.JA to "不正なリクエストです。キャラクター情報を確認してください。" + ), + "chat.room.quota.global_free_exhausted" to mapOf( + Lang.KO to "오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.", + Lang.EN to "Today's free chats have been used up. Please try again tomorrow.", + Lang.JA to "本日の無料チャットはすべて使い切りました。明日またご利用ください。" + ), + "chat.room.quota.room_free_exhausted" to mapOf( + Lang.KO to "무료 채팅이 모두 소진되었습니다.", + Lang.EN to "Free chats have been used up.", + Lang.JA to "無料チャットはすべて使い切りました。" + ) + ) + + private val chatRoomMessages = mapOf( + "chat.room.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ), + "chat.room.not_ai_room" to mapOf( + Lang.KO to "AI 캐릭터 채팅방이 아닙니다.", + Lang.EN to "This is not an AI character chat room.", + Lang.JA to "AIキャラクターのチャットルームではありません。" + ), + "chat.message.not_found" to mapOf( + Lang.KO to "메시지를 찾을 수 없습니다.", + Lang.EN to "Message not found.", + Lang.JA to "メッセージが見つかりません。" + ), + "chat.message.inactive" to mapOf( + Lang.KO to "비활성화된 메시지입니다.", + Lang.EN to "This message is inactive.", + Lang.JA to "無効化されたメッセージです。" + ), + "chat.message.not_purchasable" to mapOf( + Lang.KO to "구매할 수 없는 메시지입니다.", + Lang.EN to "This message cannot be purchased.", + Lang.JA to "購入できないメッセージです。" + ), + "chat.purchase.invalid_price" to mapOf( + Lang.KO to "구매 가격이 잘못되었습니다.", + Lang.EN to "Invalid purchase price.", + Lang.JA to "購入価格が正しくありません。" + ), + "chat.room.character_not_found" to mapOf( + Lang.KO to "해당 ID의 캐릭터를 찾을 수 없습니다.", + Lang.EN to "Character not found for the given ID.", + Lang.JA to "該当IDのキャラクターが見つかりません。" + ), + "chat.room.create_failed_retry" to mapOf( + Lang.KO to "채팅방 생성에 실패했습니다. 다시 시도해 주세요.", + Lang.EN to "Failed to create the chat room. Please try again.", + Lang.JA to "チャットルームの作成に失敗しました。もう一度お試しください。" + ), + "chat.error.retry" to mapOf( + Lang.KO to "오류가 발생했습니다. 다시 시도해 주세요.", + Lang.EN to "An error occurred. Please try again.", + Lang.JA to "エラーが発生しました。もう一度お試しください。" + ), + "chat.room.session_end_failed" to mapOf( + Lang.KO to "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요.", + Lang.EN to "Failed to end the chat room session. Please try again.", + Lang.JA to "チャットルームのセッション終了に失敗しました。もう一度お試しください。" + ), + "chat.message.send_failed" to mapOf( + Lang.KO to "메시지 전송을 실패했습니다.", + Lang.EN to "Failed to send the message.", + Lang.JA to "メッセージの送信に失敗しました。" + ), + "chat.room.last_message_image" to mapOf( + Lang.KO to "[이미지]", + Lang.EN to "[Image]", + Lang.JA to "[画像]" + ), + "chat.room.time.just_now" to mapOf( + Lang.KO to "방금", + Lang.EN to "Just now", + Lang.JA to "たった今" + ), + "chat.room.time.minutes_ago" to mapOf( + Lang.KO to "%d분 전", + Lang.EN to "%d minutes ago", + Lang.JA to "%d分前" + ), + "chat.room.time.hours_ago" to mapOf( + Lang.KO to "%d시간 전", + Lang.EN to "%d hours ago", + Lang.JA to "%d時間前" + ) + ) + private val creatorCommunityMessages = mapOf( "creator.community.paid_post_image_required" to mapOf( Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.", @@ -1772,6 +2016,14 @@ class SodaMessageSource { creatorAdminContentMessages, creatorAdminSeriesRequestMessages, creatorAdminSeriesMessages, + chatCharacterCommentMessages, + chatCharacterMessages, + chatCharacterImageMessages, + chatCharacterBannerMessages, + chatOriginalWorkMessages, + chatQuotaMessages, + chatRoomQuotaMessages, + chatRoomMessages, creatorCommunityMessages ) for (messages in messageGroups) { -- 2.49.1 From e987a56544f469e2e784e2c04d849df3dce85ef8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 19:03:38 +0900 Subject: [PATCH 84/90] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentController.kt | 26 +-- .../sodalive/content/AudioContentService.kt | 104 ++++++---- .../content/category/CategoryController.kt | 10 +- .../content/category/CategoryService.kt | 8 +- .../comment/AudioContentCommentController.kt | 8 +- .../comment/AudioContentCommentService.kt | 23 ++- .../AudioContentDonationController.kt | 2 +- .../donation/AudioContentDonationService.kt | 6 +- .../main/AudioContentMainController.kt | 14 +- .../sodalive/i18n/SodaMessageSource.kt | 178 ++++++++++++++++++ 10 files changed, 303 insertions(+), 76 deletions(-) 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 5cddb914..46fd18da 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentController.kt @@ -36,7 +36,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.createAudioContent( @@ -57,7 +57,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestPart("request") requestString: String, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.modifyAudioContent( @@ -74,7 +74,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestBody request: UploadCompleteRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.uploadComplete( @@ -91,7 +91,7 @@ class AudioContentController(private val service: AudioContentService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.deleteAudioContent( @@ -111,7 +111,7 @@ class AudioContentController(private val service: AudioContentService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAudioContentList( @@ -134,7 +134,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getDetail( @@ -151,7 +151,7 @@ class AudioContentController(private val service: AudioContentService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.generateUrl(contentId = id, member = member)) } @@ -160,7 +160,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestBody request: AddAllPlaybackTrackingRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.addAllPlaybackTracking(request, member)) } @@ -170,7 +170,7 @@ class AudioContentController(private val service: AudioContentService) { @RequestBody request: PutAudioContentLikeRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.audioContentLike(request, member)) } @@ -179,7 +179,7 @@ class AudioContentController(private val service: AudioContentService) { fun getAudioContentRankingSort( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getContentRankingSortTypeList()) } @@ -221,7 +221,7 @@ class AudioContentController(private val service: AudioContentService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.pinToTheTop(contentId = id, member = member)) } @@ -232,7 +232,7 @@ class AudioContentController(private val service: AudioContentService) { @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member)) } @@ -248,7 +248,7 @@ class AudioContentController(private val service: AudioContentService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getLatestContentByTheme( 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 15be2f77..8c1eb9d9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -31,6 +31,7 @@ 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.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService @@ -74,6 +75,7 @@ class AudioContentService( private val audioContentCloudFront: AudioContentCloudFront, private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, private val langContext: LangContext, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, @@ -117,7 +119,7 @@ class AudioContentService( val request = objectMapper.readValue(requestString, ModifyAudioContentRequest::class.java) val audioContent = repository.findByIdAndCreatorId(request.contentId, member.id!!) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") if (request.title != null) audioContent.title = request.title if (request.detail != null) audioContent.detail = request.detail @@ -189,7 +191,7 @@ class AudioContentService( @Transactional fun deleteAudioContent(audioContentId: Long, member: Member) { val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") audioContent.isActive = false audioContent.releaseDate = null @@ -203,7 +205,7 @@ class AudioContentService( member: Member ): CreateAudioContentResponse { // coverImage 체크 - if (coverImage == null) throw SodaException("커버이미지를 선택해 주세요.") + if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required") // request 내용 파싱 val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java) @@ -222,18 +224,18 @@ class AudioContentService( // contentFile 체크 if (contentFile == null) { - throw SodaException("콘텐츠를 선택해 주세요.") + throw SodaException(messageKey = "content.error.content_required") } // 테마 체크 val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId) - ?: throw SodaException("잘못된 테마입니다. 다시 선택해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_theme") if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) { - throw SodaException("알람, 모닝콜, 슬립콜 테마의 콘텐츠는 5캔 이상의 유료콘텐츠로 등록이 가능합니다.") + throw SodaException(messageKey = "content.error.alarm_theme_price_min") } - if (request.price in 1..4) throw SodaException("콘텐츠의 최소금액은 5캔 입니다.") + if (request.price in 1..4) throw SodaException(messageKey = "content.error.minimum_price") val isFullDetailVisible = if (request.price >= 50) { request.isFullDetailVisible @@ -388,34 +390,34 @@ class AudioContentService( if (previewStartTime != null && previewEndTime != null) { val startTimeArray = previewStartTime.split(":") if (startTimeArray.size != 3) { - throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") + throw SodaException(messageKey = "content.error.preview_time_format") } for (time in startTimeArray) { if (time.length != 2) { - throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") + throw SodaException(messageKey = "content.error.preview_time_format") } } val endTimeArray = previewEndTime.split(":") if (endTimeArray.size != 3) { - throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") + throw SodaException(messageKey = "content.error.preview_time_format") } for (time in endTimeArray) { if (time.length != 2) { - throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다") + throw SodaException(messageKey = "content.error.preview_time_format") } } val timeDifference = timeDifference(previewStartTime, previewEndTime) if (timeDifference < 15000) { - throw SodaException("미리 듣기의 최소 시간은 15초 입니다.") + throw SodaException(messageKey = "content.error.preview_time_minimum") } } else { if (previewStartTime != null || previewEndTime != null) { - throw SodaException("미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.") + throw SodaException(messageKey = "content.error.preview_time_both_required") } } } @@ -445,10 +447,10 @@ class AudioContentService( @Transactional fun uploadComplete(contentId: Long, content: String, duration: String) { val keyFileName = content.split("/").last() - if (!keyFileName.startsWith(contentId.toString())) throw SodaException("잘못된 요청입니다.") + if (!keyFileName.startsWith(contentId.toString())) throw SodaException(messageKey = "common.error.invalid_request") val audioContent = repository.findByIdOrNull(contentId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") audioContent.content = content audioContent.duration = duration @@ -456,7 +458,7 @@ class AudioContentService( applicationEventPublisher.publishEvent( FcmEvent( type = FcmEventType.INDIVIDUAL, - title = "콘텐츠 등록완료", + title = formatMessage("content.notification.upload_complete_title"), message = audioContent.title, recipients = listOf(audioContent.member!!.id!!), isAuth = null, @@ -471,7 +473,7 @@ class AudioContentService( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, - message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + message = formatMessage("content.notification.uploaded_message", audioContent.title), isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, @@ -483,7 +485,7 @@ class AudioContentService( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, - message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + message = formatMessage("content.notification.uploaded_message", audioContent.title), isAuth = audioContent.isAdult, contentId = contentId, creatorId = audioContent.member!!.id, @@ -505,7 +507,7 @@ class AudioContentService( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, - message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + message = formatMessage("content.notification.uploaded_message", audioContent.title), isAuth = audioContent.isAdult, contentId = audioContent.id!!, creatorId = audioContent.member!!.id, @@ -517,7 +519,7 @@ class AudioContentService( FcmEvent( type = FcmEventType.UPLOAD_CONTENT, title = audioContent.member!!.nickname, - message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}", + message = formatMessage("content.notification.uploaded_message", audioContent.title), isAuth = audioContent.isAdult, contentId = audioContent.id!!, creatorId = audioContent.member!!.id, @@ -538,12 +540,12 @@ class AudioContentService( // 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH) val audioContent = repository.findByIdOrNull(id) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") // 크리에이터(유저) 정보 val creatorId = audioContent.member!!.id!! val creator = explorerQueryRepository.getMember(creatorId) - ?: throw SodaException("없는 사용자 입니다.") + ?: throw SodaException(messageKey = "content.error.user_not_found") val creatorFollowing = explorerQueryRepository.getCreatorFollowing( creatorId = creatorId, @@ -557,7 +559,9 @@ class AudioContentService( // 차단된 사용자 체크 val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId) - if (isBlocked && !isExistsAudioContent) throw SodaException("${creator.nickname}님의 요청으로 콘텐츠 접근이 제한됩니다.") + if (isBlocked && !isExistsAudioContent) { + throw SodaException(formatMessage("content.error.access_restricted_by_creator", creator.nickname)) + } val orderSequence = if (isExistsAudioContent) { limitedEditionOrderRepository.getOrderSequence( @@ -595,7 +599,7 @@ class AudioContentService( audioContent.releaseDate != null && audioContent.releaseDate!! < LocalDateTime.now() ) { - throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "content.error.invalid_content_retry") } // 댓글 @@ -628,11 +632,13 @@ class AudioContentService( audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now() ) { + val releaseDatePattern = messageSource.getMessage("content.release_date.format", langContext.lang) + ?: "yyyy년 MM월 dd일 HH시 mm분 오픈예정" audioContent.releaseDate!! .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of("Asia/Seoul")) .toLocalDateTime() - .format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분 오픈예정")) + .format(DateTimeFormatter.ofPattern(releaseDatePattern, langContext.lang.locale)) } else { null } @@ -1114,8 +1120,13 @@ class AudioContentService( limit: Long, sortType: String = "매출" ): GetAudioContentRanking { - val startDateFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일") - val endDateFormatter = DateTimeFormatter.ofPattern("MM월 dd일") + val normalizedSortType = normalizeRankingSortType(sortType) + val startDatePattern = messageSource.getMessage("content.ranking.date.start_format", langContext.lang) + ?: "yyyy년 MM월 dd일" + val endDatePattern = messageSource.getMessage("content.ranking.date.end_format", langContext.lang) + ?: "MM월 dd일" + val startDateFormatter = DateTimeFormatter.ofPattern(startDatePattern, langContext.lang.locale) + val endDateFormatter = DateTimeFormatter.ofPattern(endDatePattern, langContext.lang.locale) val contentRankingItemList = repository .getAudioContentRanking( @@ -1126,7 +1137,7 @@ class AudioContentService( contentType = contentType, offset = offset, limit = limit, - sortType = sortType + sortType = normalizedSortType ) return GetAudioContentRanking( @@ -1137,16 +1148,19 @@ class AudioContentService( } fun getContentRankingSortTypeList(): List { - return listOf("매출", "댓글", "좋아요") + val salesLabel = messageSource.getMessage("content.ranking.sort_type.sales", langContext.lang) ?: "매출" + val commentLabel = messageSource.getMessage("content.ranking.sort_type.comment", langContext.lang) ?: "댓글" + val likeLabel = messageSource.getMessage("content.ranking.sort_type.like", langContext.lang) ?: "좋아요" + return listOf(salesLabel, commentLabel, likeLabel) } @Transactional fun pinToTheTop(contentId: Long, member: Member) { val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) { - throw SodaException("콘텐츠 오픈 후 채널에 고정이 가능합니다.") + throw SodaException(messageKey = "content.error.pin_available_after_open") } var pinContent = pinContentRepository.findByContentIdAndMemberId( @@ -1176,14 +1190,14 @@ class AudioContentService( val pinContent = pinContentRepository.findByContentIdAndMemberId( contentId = contentId, memberId = member.id!! - ) ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ) ?: throw SodaException(messageKey = "content.error.invalid_content_retry") pinContent.isActive = false } fun generateUrl(contentId: Long, member: Member): GenerateUrlResponse { val audioContent = repository.findByIdOrNull(contentId) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") val isExistsAudioContent = orderRepository.isExistOrdered( memberId = member.id!!, @@ -1312,4 +1326,28 @@ class AudioContentService( .distinct() .toList() } + + private fun normalizeRankingSortType(sortType: String?): String { + val trimmed = sortType?.trim().orEmpty() + val internalTypes = setOf("매출", "댓글", "좋아요", "후원") + if (trimmed in internalTypes) return trimmed + + val salesLabel = messageSource.getMessage("content.ranking.sort_type.sales", langContext.lang) + val commentLabel = messageSource.getMessage("content.ranking.sort_type.comment", langContext.lang) + val likeLabel = messageSource.getMessage("content.ranking.sort_type.like", langContext.lang) + val donationLabel = messageSource.getMessage("content.ranking.sort_type.donation", langContext.lang) + + return when (trimmed) { + salesLabel -> "매출" + commentLabel -> "댓글" + likeLabel -> "좋아요" + donationLabel -> "후원" + else -> "매출" + } + } + + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryController.kt index 0906e5f4..05aba076 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryController.kt @@ -24,7 +24,7 @@ class CategoryController(private val service: CategoryService) { @RequestBody request: CreateCategoryRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.createCategory(request = request, member = member)) } @@ -35,7 +35,7 @@ class CategoryController(private val service: CategoryService) { @RequestBody request: ModifyCategoryRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.modifyCategory(request = request, member = member)) } @@ -46,7 +46,7 @@ class CategoryController(private val service: CategoryService) { @RequestBody request: UpdateCategoryOrdersRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.updateCategoryOrders(request = request, member = member)) } @@ -57,7 +57,7 @@ class CategoryController(private val service: CategoryService) { @PathVariable("id") categoryId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.deleteCategory(categoryId = categoryId, member = member)) } @@ -67,7 +67,7 @@ class CategoryController(private val service: CategoryService) { @RequestParam creatorId: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getCategoryList(creatorId = creatorId, memberId = member.id!!)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt index 1fc15860..04291ffb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryService.kt @@ -66,7 +66,7 @@ class CategoryService( @Transactional fun modifyCategory(request: ModifyCategoryRequest, member: Member) { val category = repository.findByIdAndMemberId(categoryId = request.categoryId, memberId = member.id!!) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (!request.title.isNullOrBlank()) { validateTitle(title = request.title) @@ -108,7 +108,7 @@ class CategoryService( @Transactional fun deleteCategory(categoryId: Long, member: Member) { val category = repository.findByIdAndMemberId(categoryId = categoryId, memberId = member.id!!) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") category.isActive = false categoryContentRepository.deleteByCategoryId(categoryId = categoryId) @@ -128,7 +128,7 @@ class CategoryService( @Transactional fun getCategoryList(creatorId: Long, memberId: Long): List { val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId) - if (isBlocked) throw SodaException("잘못된 접근입니다.") + if (isBlocked) throw SodaException(messageKey = "category.error.invalid_access") // 기본 카테고리 목록 조회 (원본 언어 기준) val baseList = repository.findByCreatorId(creatorId = creatorId) @@ -205,6 +205,6 @@ class CategoryService( } private fun validateTitle(title: String) { - if (title.length < 2) throw SodaException("카테고리명은 2글자 이상 입력하세요") + if (title.length < 2) throw SodaException(messageKey = "category.error.title_min_length") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index ad91270e..9a6e56cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -25,7 +25,7 @@ class AudioContentCommentController( @RequestBody request: RegisterCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val commentId = service.registerComment( comment = request.comment, @@ -62,7 +62,7 @@ class AudioContentCommentController( @RequestBody request: ModifyCommentRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.modifyComment(request = request, member = member)) } @@ -74,7 +74,7 @@ class AudioContentCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getCommentList( @@ -93,7 +93,7 @@ class AudioContentCommentController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ): ApiResponse { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") return ApiResponse.ok( service.getCommentReplyList( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index c8663a28..3746b449 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt @@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import org.springframework.beans.factory.annotation.Value @@ -24,6 +26,8 @@ class AudioContentCommentService( private val audioContentRepository: AudioContentRepository, private val applicationEventPublisher: ApplicationEventPublisher, private val orderRepository: OrderRepository, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -38,11 +42,13 @@ class AudioContentCommentService( languageCode: String? ): Long { val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") val creator = audioContent.member!! val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creator.id!!) - if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 댓글쓰기가 제한됩니다.") + if (isBlocked) { + throw SodaException(formatMessage("content.comment.error.blocked_by_creator", creator.nickname)) + } val (isExistsAudioContent, _) = orderRepository.isExistOrderedAndOrderType( memberId = member.id!!, @@ -50,7 +56,7 @@ class AudioContentCommentService( ) if (isSecret && !isExistsAudioContent) { - throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.") + throw SodaException(messageKey = "content.comment.error.secret_requires_purchase") } val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret) @@ -78,9 +84,9 @@ class AudioContentCommentService( member.nickname }, message = if (parent != null) { - "댓글에 답글을 달았습니다.: ${audioContent.title}" + formatMessage("content.comment.notification.reply", audioContent.title) } else { - "콘텐츠에 댓글을 달았습니다.: ${audioContent.title}" + formatMessage("content.comment.notification.new", audioContent.title) }, contentId = audioContentId, commentParentId = parentId, @@ -105,7 +111,7 @@ class AudioContentCommentService( @Transactional fun modifyComment(request: ModifyCommentRequest, member: Member) { val audioContentComment = repository.findByIdOrNull(request.commentId) - ?: throw SodaException("잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.comment.error.invalid_access_retry") if (audioContentComment.member!!.id!! == member.id!!) { if (request.comment != null) { @@ -164,4 +170,9 @@ class AudioContentCommentService( return GetAudioContentCommentListResponse(totalCount, commentList) } + + private fun formatMessage(key: String, vararg args: Any): String { + val template = messageSource.getMessage(key, langContext.lang) ?: return "" + return String.format(template, *args) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt index ebd097a7..f6a0dddf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationController.kt @@ -18,7 +18,7 @@ class AudioContentDonationController(private val service: AudioContentDonationSe @RequestBody request: AudioContentDonationRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.donation(request = request, member = member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt index 99a4da50..7fc81343 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/donation/AudioContentDonationService.kt @@ -22,11 +22,11 @@ class AudioContentDonationService( ) { @Transactional fun donation(request: AudioContentDonationRequest, member: Member) { - if (request.donationCan < 1) throw SodaException("1캔 이상 후원하실 수 있습니다.") - if (request.comment.isBlank()) throw SodaException("함께 보낼 메시지를 입력하세요.") + if (request.donationCan < 1) throw SodaException(messageKey = "content.donation.error.minimum_can") + if (request.comment.isBlank()) throw SodaException(messageKey = "content.donation.error.comment_required") val audioContent = queryRepository.findByIdAndActive(request.contentId) - ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") canPaymentService.spendCan( memberId = member.id!!, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt index c534be5d..df73f542 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainController.kt @@ -22,7 +22,7 @@ class AudioContentMainController( fun newContentUploadCreatorList( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getNewContentUploadCreatorList( @@ -36,7 +36,7 @@ class AudioContentMainController( fun getMainBannerList( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAudioContentMainBannerList( @@ -50,7 +50,7 @@ class AudioContentMainController( fun getMainOrderList( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( orderService.getAudioContentMainOrderList( @@ -68,7 +68,7 @@ class AudioContentMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getNewContentByTheme( @@ -87,7 +87,7 @@ class AudioContentMainController( @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getThemeList( @@ -105,7 +105,7 @@ class AudioContentMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getNewContentFor2WeeksByTheme( @@ -125,7 +125,7 @@ class AudioContentMainController( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAudioContentCurationListWithPaging( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 16624887..e67519c3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -47,6 +47,177 @@ class SodaMessageSource { ) ) + private val contentErrorMessages = mapOf( + "content.error.invalid_content_retry" to mapOf( + Lang.KO to "잘못된 콘텐츠 입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid content.\nPlease try again.", + Lang.JA to "不正なコンテンツです。\nもう一度お試しください。" + ), + "content.error.cover_image_required" to mapOf( + Lang.KO to "커버이미지를 선택해 주세요.", + Lang.EN to "Please select a cover image.", + Lang.JA to "カバー画像を選択してください。" + ), + "content.error.content_required" to mapOf( + Lang.KO to "콘텐츠를 선택해 주세요.", + Lang.EN to "Please select content.", + Lang.JA to "コンテンツを選択してください。" + ), + "content.error.invalid_theme" to mapOf( + Lang.KO to "잘못된 테마입니다. 다시 선택해 주세요.", + Lang.EN to "Invalid theme. Please select again.", + Lang.JA to "不正なテーマです。もう一度選択してください。" + ), + "content.error.alarm_theme_price_min" to mapOf( + Lang.KO to "알람, 모닝콜, 슬립콜 테마의 콘텐츠는 5캔 이상의 유료콘텐츠로 등록이 가능합니다.", + Lang.EN to "Alarm, Morning Call, and Sleep Call themes require paid content of at least 5 cans.", + Lang.JA to "アラーム、モーニングコール、スリープコールのテーマは5缶以上の有料コンテンツのみ登録できます。" + ), + "content.error.minimum_price" to mapOf( + Lang.KO to "콘텐츠의 최소금액은 5캔 입니다.", + Lang.EN to "The minimum price for content is 5 cans.", + Lang.JA to "コンテンツの最低価格は5缶です。" + ), + "content.error.preview_time_format" to mapOf( + Lang.KO to "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다", + Lang.EN to "Preview time format must be like 00:30:00.", + Lang.JA to "プレビュー時間の形式は00:30:00のようにする必要があります。" + ), + "content.error.preview_time_minimum" to mapOf( + Lang.KO to "미리 듣기의 최소 시간은 15초 입니다.", + Lang.EN to "The minimum preview time is 15 seconds.", + Lang.JA to "プレビューの最小時間は15秒です。" + ), + "content.error.preview_time_both_required" to mapOf( + Lang.KO to "미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.", + Lang.EN to "You must enter both preview start and end times, or neither.", + Lang.JA to "プレビューの開始時間と終了時間は両方入力するか、両方入力しないでください。" + ), + "content.error.user_not_found" to mapOf( + Lang.KO to "없는 사용자 입니다.", + Lang.EN to "User not found.", + Lang.JA to "ユーザーが見つかりません。" + ), + "content.error.access_restricted_by_creator" to mapOf( + Lang.KO to "%s님의 요청으로 콘텐츠 접근이 제한됩니다.", + Lang.EN to "Access to content is restricted at %s's request.", + Lang.JA to "%sさんの要請によりコンテンツへのアクセスが制限されています。" + ), + "content.error.pin_available_after_open" to mapOf( + Lang.KO to "콘텐츠 오픈 후 채널에 고정이 가능합니다.", + Lang.EN to "You can pin it to the channel after the content is opened.", + Lang.JA to "コンテンツ公開後にチャンネルへ固定できます。" + ) + ) + + private val contentNotificationMessages = mapOf( + "content.notification.upload_complete_title" to mapOf( + Lang.KO to "콘텐츠 등록완료", + Lang.EN to "Content registration complete", + Lang.JA to "コンテンツ登録完了" + ), + "content.notification.uploaded_message" to mapOf( + Lang.KO to "콘텐츠를 업로드 하였습니다. - %s", + Lang.EN to "Content uploaded. - %s", + Lang.JA to "コンテンツをアップロードしました。- %s" + ) + ) + + private val contentFormatMessages = mapOf( + "content.release_date.format" to mapOf( + Lang.KO to "yyyy년 MM월 dd일 HH시 mm분 오픈예정", + Lang.EN to "MMM dd, yyyy HH:mm 'Opens soon'", + Lang.JA to "yyyy年 MM月 dd日 HH時 mm分 公開予定" + ), + "content.ranking.date.start_format" to mapOf( + Lang.KO to "yyyy년 MM월 dd일", + Lang.EN to "MMM dd, yyyy", + Lang.JA to "yyyy年 MM月 dd日" + ), + "content.ranking.date.end_format" to mapOf( + Lang.KO to "MM월 dd일", + Lang.EN to "MMM dd", + Lang.JA to "MM月 dd日" + ) + ) + + private val contentRankingMessages = mapOf( + "content.ranking.sort_type.sales" to mapOf( + Lang.KO to "매출", + Lang.EN to "Sales", + Lang.JA to "売上" + ), + "content.ranking.sort_type.comment" to mapOf( + Lang.KO to "댓글", + Lang.EN to "Comments", + Lang.JA to "コメント" + ), + "content.ranking.sort_type.like" to mapOf( + Lang.KO to "좋아요", + Lang.EN to "Likes", + Lang.JA to "いいね" + ), + "content.ranking.sort_type.donation" to mapOf( + Lang.KO to "후원", + Lang.EN to "Donations", + Lang.JA to "支援" + ) + ) + + private val contentCommentMessages = mapOf( + "content.comment.error.blocked_by_creator" to mapOf( + Lang.KO to "%s님의 요청으로 댓글쓰기가 제한됩니다.", + Lang.EN to "Commenting is restricted at %s's request.", + Lang.JA to "%sさんの要請によりコメントの投稿が制限されています。" + ), + "content.comment.error.secret_requires_purchase" to mapOf( + Lang.KO to "콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.", + Lang.EN to "You can post a secret comment after purchasing the content.", + Lang.JA to "コンテンツ購入後に秘密コメントを登録できます。" + ), + "content.comment.notification.reply" to mapOf( + Lang.KO to "댓글에 답글을 달았습니다.: %s", + Lang.EN to "Replied to a comment: %s", + Lang.JA to "コメントに返信しました: %s" + ), + "content.comment.notification.new" to mapOf( + Lang.KO to "콘텐츠에 댓글을 달았습니다.: %s", + Lang.EN to "Commented on content: %s", + Lang.JA to "コンテンツにコメントしました: %s" + ), + "content.comment.error.invalid_access_retry" to mapOf( + Lang.KO to "잘못된 접근 입니다.\n확인 후 다시 시도해 주세요.", + Lang.EN to "Invalid access.\nPlease check and try again.", + Lang.JA to "不正なアクセスです。\n確認して再度お試しください。" + ) + ) + + private val contentDonationMessages = mapOf( + "content.donation.error.minimum_can" to mapOf( + Lang.KO to "1캔 이상 후원하실 수 있습니다.", + Lang.EN to "You can donate at least 1 can.", + Lang.JA to "1缶以上寄付できます。" + ), + "content.donation.error.comment_required" to mapOf( + Lang.KO to "함께 보낼 메시지를 입력하세요.", + Lang.EN to "Please enter a message to send.", + Lang.JA to "一緒に送るメッセージを入力してください。" + ) + ) + + private val categoryMessages = mapOf( + "category.error.invalid_access" to mapOf( + Lang.KO to "잘못된 접근입니다.", + Lang.EN to "Invalid access.", + Lang.JA to "不正なアクセスです。" + ), + "category.error.title_min_length" to mapOf( + Lang.KO to "카테고리명은 2글자 이상 입력하세요", + Lang.EN to "Category name must be at least 2 characters.", + Lang.JA to "カテゴリ名は2文字以上入力してください。" + ) + ) + private val alarmMessages = mapOf( "alarm.error.already_purchased" to mapOf( Lang.KO to "이미 구매하셨습니다", @@ -1952,6 +2123,13 @@ class SodaMessageSource { fun getMessage(key: String, lang: Lang): String? { val messageGroups = listOf( commonMessages, + contentErrorMessages, + contentNotificationMessages, + contentFormatMessages, + contentRankingMessages, + contentCommentMessages, + contentDonationMessages, + categoryMessages, alarmMessages, auditionMessages, auditionRequestMessages, -- 2.49.1 From 60e654cda99f4b5b460ee318ac08d5d7c063f5a8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 19:22:06 +0900 Subject: [PATCH 85/90] =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=8B=A4=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/order/OrderController.kt | 4 +- .../sodalive/content/order/OrderService.kt | 16 ++-- .../AudioContentPlaylistController.kt | 10 +-- .../playlist/AudioContentPlaylistService.kt | 22 ++--- .../content/series/ContentSeriesController.kt | 8 +- .../content/series/ContentSeriesService.kt | 4 +- .../series/main/SeriesMainController.kt | 10 +-- .../main/banner/ContentSeriesBannerService.kt | 16 ++-- .../theme/AudioContentThemeController.kt | 6 +- .../content/theme/AudioContentThemeService.kt | 2 +- .../sodalive/i18n/SodaMessageSource.kt | 81 +++++++++++++++++++ 11 files changed, 132 insertions(+), 47 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt index 89c1e087..bce18770 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderController.kt @@ -19,7 +19,7 @@ class OrderController(private val service: OrderService) { @RequestBody request: OrderRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.order( @@ -36,7 +36,7 @@ class OrderController(private val service: OrderService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getAudioContentOrderList( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt index 68de04a7..6db421aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/order/OrderService.kt @@ -33,11 +33,11 @@ class OrderService( @Transactional fun order(contentId: Long, orderType: OrderType, container: String, member: Member) { val content = audioContentRepository.findByIdAndActive(contentId) - ?: throw SodaException("잘못된 콘텐츠 입니다\n다시 시도해 주세요.") + ?: throw SodaException(messageKey = "content.error.invalid_content_retry") validateOrder(memberId = member.id!!, content = content, orderType = orderType) val order = if (content.limited != null && content.remaining != null) { - if (content.remaining!! <= 0) throw SodaException("해당 콘텐츠가 매진되었습니다.") + if (content.remaining!! <= 0) throw SodaException(messageKey = "order.error.content_sold_out") orderLimitedEditionContent(content, member) } else { orderContent(orderType, content, member) @@ -93,16 +93,20 @@ class OrderService( } private fun validateOrder(memberId: Long, content: AudioContent, orderType: OrderType) { - if (memberId == content.member!!.id!!) throw SodaException("자신이 올린 콘텐츠는 구매할 수 없습니다.") + if (memberId == content.member!!.id!!) { + throw SodaException(messageKey = "order.error.cannot_purchase_own_content") + } val existOrdered = repository.isExistOrdered(memberId = memberId, contentId = content.id!!) - if (existOrdered) throw SodaException("이미 구매한 콘텐츠 입니다.") + if (existOrdered) throw SodaException(messageKey = "order.error.already_purchased") val isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental - if (isOnlyRental && orderType == OrderType.KEEP) throw SodaException("대여만 가능한 콘텐츠 입니다.") + if (isOnlyRental && orderType == OrderType.KEEP) { + throw SodaException(messageKey = "order.error.rental_only") + } val isOnlyBuy = content.purchaseOption == PurchaseOption.BUY_ONLY && orderType == OrderType.RENTAL - if (isOnlyBuy) throw SodaException("소장만 가능한 콘텐츠 입니다.\n앱 업데이트 후 구매해 주세요.") + if (isOnlyBuy) throw SodaException(messageKey = "order.error.keep_only_update_required") } fun getAudioContentOrderList( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt index d3684414..b18afe2f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistController.kt @@ -21,7 +21,7 @@ class AudioContentPlaylistController(private val service: AudioContentPlaylistSe @RequestBody request: CreatePlaylistRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.createPlaylist(request, member)) } @@ -32,7 +32,7 @@ class AudioContentPlaylistController(private val service: AudioContentPlaylistSe @RequestBody request: UpdatePlaylistRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.updatePlaylist(playlistId = id, request = request, member = member)) } @@ -42,7 +42,7 @@ class AudioContentPlaylistController(private val service: AudioContentPlaylistSe @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.deletePlaylist(playlistId = id, member)) } @@ -51,7 +51,7 @@ class AudioContentPlaylistController(private val service: AudioContentPlaylistSe fun getPlaylists( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getPlaylists(member)) } @@ -61,7 +61,7 @@ class AudioContentPlaylistController(private val service: AudioContentPlaylistSe @PathVariable id: Long, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getPlaylistDetail(playlistId = id, member = member)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt index f224ab07..367eae3c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/playlist/AudioContentPlaylistService.kt @@ -20,12 +20,12 @@ class AudioContentPlaylistService( ) { fun createPlaylist(request: CreatePlaylistRequest, member: Member) { if (request.contentIdAndOrderList.size >= 30) { - throw SodaException("플레이 리스트에는 최대 30개의 콘텐츠를 저장할 수 있습니다.") + throw SodaException(messageKey = "playlist.error.max_content_limit") } val playlistCount = redisRepository.findByMemberId(member.id!!).size if (playlistCount >= 10) { - throw SodaException("플레이 리스트는 최대 10개까지 생성할 수 있습니다.") + throw SodaException(messageKey = "playlist.error.max_playlist_limit") } val contentIdAndOrderList = validateAndGetContentIdAndOrderList( @@ -68,7 +68,7 @@ class AudioContentPlaylistService( private fun validateContent(contentIdList: List, memberId: Long) { if (contentIdList.isEmpty()) { - throw SodaException("콘텐츠를 1개 이상 추가하세요") + throw SodaException(messageKey = "playlist.error.content_required") } checkOrderedContent( @@ -83,20 +83,20 @@ class AudioContentPlaylistService( val notOrderedContentList = orderedContentMap.filterValues { !it }.keys if (notOrderedContentList.isNotEmpty()) { - throw SodaException("대여/소장하지 않은 콘텐츠는 재생목록에 추가할 수 없습니다.") + throw SodaException(messageKey = "playlist.error.not_purchased_content") } } fun updatePlaylist(playlistId: Long, request: UpdatePlaylistRequest, member: Member) { if (request.contentIdAndOrderList.size >= 30) { - throw SodaException("플레이 리스트에는 최대 30개의 콘텐츠를 저장할 수 있습니다.") + throw SodaException(messageKey = "playlist.error.max_content_limit") } val playlist = redisRepository.findByIdOrNull(id = playlistId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (playlist.memberId != member.id) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } val contentIdAndOrderList = validateAndGetContentIdAndOrderList( @@ -145,10 +145,10 @@ class AudioContentPlaylistService( fun deletePlaylist(playlistId: Long, member: Member) { val playlist = redisRepository.findByIdOrNull(id = playlistId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (playlist.memberId != member.id) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } redisRepository.delete(playlist) @@ -156,10 +156,10 @@ class AudioContentPlaylistService( fun getPlaylistDetail(playlistId: Long, member: Member): GetPlaylistDetailResponse { val playlist = redisRepository.findByIdOrNull(id = playlistId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") if (playlist.memberId != member.id) { - throw SodaException("잘못된 요청입니다.") + throw SodaException(messageKey = "common.error.invalid_request") } val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt index e9f69fd6..a844e2f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesController.kt @@ -26,7 +26,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSeriesList( @@ -49,7 +49,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSeriesDetail( @@ -70,7 +70,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getSeriesContentList( @@ -91,7 +91,7 @@ class ContentSeriesController(private val service: ContentSeriesService) { @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getRecommendSeriesList( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 1f8dba56..bd7ced51 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -210,11 +210,11 @@ class ContentSeriesService( seriesId = seriesId, isAuth = member.auth != null && isAdultContentVisible, contentType = contentType - ) ?: throw SodaException("잘못된 시리즈 입니다.\n다시 시도해 주세요") + ) ?: throw SodaException(messageKey = "series.error.invalid_series_retry") val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = series.member!!.id!!) if (isBlocked) { - throw SodaException("잘못된 시리즈 입니다.\n다시 시도해 주세요") + throw SodaException(messageKey = "series.error.invalid_series_retry") } val creatorFollowing = explorerQueryRepository.getCreatorFollowing( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt index 40dd0d83..c2476382 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainController.kt @@ -31,7 +31,7 @@ class SeriesMainController( @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) .content @@ -70,7 +70,7 @@ class SeriesMainController( @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( contentSeriesService.getRecommendSeriesList( @@ -90,7 +90,7 @@ class SeriesMainController( @RequestParam(defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val pageable = PageRequest.of(page, size) ApiResponse.ok( @@ -111,7 +111,7 @@ class SeriesMainController( @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val memberId = member.id!! val isAdult = member.auth != null && (isAdultContentVisible ?: true) @@ -134,7 +134,7 @@ class SeriesMainController( @RequestParam(defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val pageable = PageRequest.of(page, size) ApiResponse.ok( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt index 90f876ee..8ea977b8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/main/banner/ContentSeriesBannerService.kt @@ -18,13 +18,13 @@ class ContentSeriesBannerService( fun getBannerById(bannerId: Long): SeriesBanner { return bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } } @Transactional fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { val series = seriesRepository.findByIdAndActiveTrue(seriesId) - ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + ?: throw SodaException(messageKey = "series.banner.error.series_not_found") val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 @@ -43,14 +43,14 @@ class ContentSeriesBannerService( seriesId: Long? = null ): SeriesBanner { val banner = bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } - if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") + .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } + if (!banner.isActive) throw SodaException(messageKey = "series.banner.error.inactive_cannot_update") if (imagePath != null) banner.imagePath = imagePath if (seriesId != null) { val series = seriesRepository.findByIdAndActiveTrue(seriesId) - ?: throw SodaException("시리즈를 찾을 수 없습니다: $seriesId") + ?: throw SodaException(messageKey = "series.banner.error.series_not_found") banner.series = series } @@ -60,7 +60,7 @@ class ContentSeriesBannerService( @Transactional fun deleteBanner(bannerId: Long) { val banner = bannerRepository.findById(bannerId) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } banner.isActive = false bannerRepository.save(banner) } @@ -70,8 +70,8 @@ class ContentSeriesBannerService( val updated = mutableListOf() for (index in ids.indices) { val banner = bannerRepository.findById(ids[index]) - .orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } - if (!banner.isActive) throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") + .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } + if (!banner.isActive) throw SodaException(messageKey = "series.banner.error.inactive_cannot_update") banner.sortOrder = index + 1 updated.add(bannerRepository.save(banner)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt index ef843713..8f1b662c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeController.kt @@ -22,7 +22,7 @@ class AudioContentThemeController(private val service: AudioContentThemeService) fun getThemes( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok(service.getThemes()) } @@ -35,7 +35,7 @@ class AudioContentThemeController(private val service: AudioContentThemeService) @RequestParam("contentType", required = false) contentType: ContentType? = null, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getActiveThemeOfContent( @@ -56,7 +56,7 @@ class AudioContentThemeController(private val service: AudioContentThemeService) @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, pageable: Pageable ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") ApiResponse.ok( service.getContentByTheme( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index 2adff9d2..0301f86e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -119,7 +119,7 @@ class AudioContentThemeService( limit: Long ): GetContentByThemeResponse { val theme = queryRepository.findThemeByIdAndActive(themeId) - ?: throw SodaException("잘못된 요청입니다.") + ?: throw SodaException(messageKey = "common.error.invalid_request") val totalCount = contentRepository.totalCountByTheme( memberId = member.id!!, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index e67519c3..71e4ad23 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -205,6 +205,83 @@ class SodaMessageSource { ) ) + private val orderMessages = mapOf( + "order.error.content_sold_out" to mapOf( + Lang.KO to "해당 콘텐츠가 매진되었습니다.", + Lang.EN to "This content is sold out.", + Lang.JA to "このコンテンツは売り切れです。" + ), + "order.error.cannot_purchase_own_content" to mapOf( + Lang.KO to "자신이 올린 콘텐츠는 구매할 수 없습니다.", + Lang.EN to "You cannot purchase your own content.", + Lang.JA to "自分が投稿したコンテンツは購入できません。" + ), + "order.error.already_purchased" to mapOf( + Lang.KO to "이미 구매한 콘텐츠 입니다.", + Lang.EN to "This content has already been purchased.", + Lang.JA to "すでに購入したコンテンツです。" + ), + "order.error.rental_only" to mapOf( + Lang.KO to "대여만 가능한 콘텐츠 입니다.", + Lang.EN to "This content is available for rental only.", + Lang.JA to "このコンテンツはレンタルのみ可能です。" + ), + "order.error.keep_only_update_required" to mapOf( + Lang.KO to "소장만 가능한 콘텐츠 입니다.\n앱 업데이트 후 구매해 주세요.", + Lang.EN to "This content is available for purchase only.\nPlease update the app before purchasing.", + Lang.JA to "このコンテンツは購入のみ可能です。\nアプリを更新してから購入してください。" + ) + ) + + private val playlistMessages = mapOf( + "playlist.error.max_content_limit" to mapOf( + Lang.KO to "플레이 리스트에는 최대 30개의 콘텐츠를 저장할 수 있습니다.", + Lang.EN to "A playlist can contain up to 30 contents.", + Lang.JA to "プレイリストには最大30件のコンテンツを保存できます。" + ), + "playlist.error.max_playlist_limit" to mapOf( + Lang.KO to "플레이 리스트는 최대 10개까지 생성할 수 있습니다.", + Lang.EN to "You can create up to 10 playlists.", + Lang.JA to "プレイリストは最大10個まで作成できます。" + ), + "playlist.error.content_required" to mapOf( + Lang.KO to "콘텐츠를 1개 이상 추가하세요", + Lang.EN to "Please add at least one content.", + Lang.JA to "コンテンツを1つ以上追加してください。" + ), + "playlist.error.not_purchased_content" to mapOf( + Lang.KO to "대여/소장하지 않은 콘텐츠는 재생목록에 추가할 수 없습니다.", + Lang.EN to "Content you haven't rented or purchased cannot be added to the playlist.", + Lang.JA to "レンタルまたは購入していないコンテンツは再生リストに追加できません。" + ) + ) + + private val seriesMessages = mapOf( + "series.error.invalid_series_retry" to mapOf( + Lang.KO to "잘못된 시리즈 입니다.\n다시 시도해 주세요", + Lang.EN to "Invalid series.\nPlease try again.", + Lang.JA to "不正なシリーズです。\nもう一度お試しください。" + ) + ) + + private val seriesBannerMessages = mapOf( + "series.banner.error.not_found" to mapOf( + Lang.KO to "배너를 찾을 수 없습니다.", + Lang.EN to "Banner not found.", + Lang.JA to "バナーが見つかりません。" + ), + "series.banner.error.inactive_cannot_update" to mapOf( + Lang.KO to "비활성화된 배너는 수정할 수 없습니다.", + Lang.EN to "Inactive banners cannot be updated.", + Lang.JA to "無効化されたバナーは修正できません。" + ), + "series.banner.error.series_not_found" to mapOf( + Lang.KO to "시리즈를 찾을 수 없습니다.", + Lang.EN to "Series not found.", + Lang.JA to "シリーズが見つかりません。" + ) + ) + private val categoryMessages = mapOf( "category.error.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", @@ -2129,6 +2206,10 @@ class SodaMessageSource { contentRankingMessages, contentCommentMessages, contentDonationMessages, + orderMessages, + playlistMessages, + seriesMessages, + seriesBannerMessages, categoryMessages, alarmMessages, auditionMessages, -- 2.49.1 From 8357b4d73e43b0e053d9cd0224a1eb50ae35e605 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 20:24:24 +0900 Subject: [PATCH 86/90] =?UTF-8?q?java=20version=2017=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index ef92369b..6397fe8b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT" val querydslVersion = "5.0.0" java { - sourceCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 } repositories { @@ -89,7 +89,7 @@ allOpen { tasks.withType { kotlinOptions { freeCompilerArgs += "-Xjsr305=strict" - jvmTarget = "11" + jvmTarget = "17" } } -- 2.49.1 From 943a88afdb0110077081bbad7ab7ecc9970ef847 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Dec 2025 20:34:41 +0900 Subject: [PATCH 87/90] =?UTF-8?q?gradle=20=EC=86=8D=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 gradle.properties diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..8166e54f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +# Gradle ?? JVM(daemon/worker) ? +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 + +# Kotlin ??? ?? ? (?? ???? ??) +kotlin.daemon.jvmargs=-Xmx2048m + +# CI ???(?? ?? ??? ??? ?? ? ??) +org.gradle.workers.max=2 +org.gradle.parallel=false -- 2.49.1 From 78f4c5623210fe9b6caac41efddeebd2b7871335 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 29 Dec 2025 12:08:41 +0900 Subject: [PATCH 88/90] =?UTF-8?q?=ED=9B=84=EC=9B=90=20=EB=9E=AD=ED=82=B9?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/explorer/ExplorerService.kt | 9 ++- .../explorer/MemberDonationRankingResponse.kt | 19 ++++-- .../CreatorDonationRankingQueryRepository.kt | 65 +++++++++++++++++++ .../profile/CreatorDonationRankingService.kt | 53 +++++++++++++++ 4 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt index 9a81fcd9..3cad7f4e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService @@ -43,6 +44,8 @@ import kotlin.random.Random class ExplorerService( private val memberService: MemberService, private val audioContentService: AudioContentService, + private val donationRankingService: CreatorDonationRankingService, + private val queryRepository: ExplorerQueryRepository, private val cheersRepository: CreatorCheersRepository, private val noticeRepository: ChannelNoticeRepository, @@ -217,9 +220,9 @@ class ExplorerService( val memberDonationRanking = if ( isCreator && (creatorId == member.id!! || creatorAccount.isVisibleDonationRank) ) { - queryRepository.getMemberDonationRanking( - creatorId, - 10, + donationRankingService.getMemberDonationRanking( + creatorId = creatorId, + limit = 10, withDonationCan = creatorId == member.id!! ) } else { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt index 0776312a..53e7dc69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/MemberDonationRankingResponse.kt @@ -1,5 +1,8 @@ package kr.co.vividnext.sodalive.explorer +import com.fasterxml.jackson.annotation.JsonProperty +import java.io.Serializable + data class GetDonationAllResponse( val accumulatedCansToday: Int, val accumulatedCansLastWeek: Int, @@ -7,11 +10,15 @@ data class GetDonationAllResponse( val isVisibleDonationRank: Boolean, val totalCount: Int, val userDonationRanking: List -) +) : Serializable data class MemberDonationRankingResponse( - val userId: Long, - val nickname: String, - val profileImage: String, - val donationCan: Int -) + @JsonProperty("userId") val userId: Long, + @JsonProperty("nickname") val nickname: String, + @JsonProperty("profileImage") val profileImage: String, + @JsonProperty("donationCan") val donationCan: Int +) : Serializable + +data class MemberDonationRankingListResponse( + @JsonProperty("rankings") val rankings: List +) : Serializable diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt new file mode 100644 index 00000000..8929bce5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingQueryRepository.kt @@ -0,0 +1,65 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import com.fasterxml.jackson.annotation.JsonProperty +import com.querydsl.core.annotations.QueryProjection +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan +import kr.co.vividnext.sodalive.can.use.QUseCanCalculate.useCanCalculate +import kr.co.vividnext.sodalive.member.QMember.member +import org.springframework.stereotype.Repository +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.temporal.TemporalAdjusters + +@Repository +class CreatorDonationRankingQueryRepository(private val queryFactory: JPAQueryFactory) { + fun getMemberDonationRanking( + creatorId: Long, + limit: Long + ): List { + val now = LocalDateTime.now() + val lastMonday = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .minusWeeks(1) + .with(LocalTime.MIN) + val lastSunday = lastMonday.plusDays(6).with(LocalTime.MAX) + + val donationCan = useCan.rewardCan.add(useCan.can).sum() + return queryFactory + .select( + QDonationRankingProjection( + member.id, + member.nickname, + member.profileImage, + donationCan + ) + ) + .from(useCanCalculate) + .innerJoin(useCanCalculate.useCan, useCan) + .innerJoin(useCan.member, member) + .where( + useCan.member.isActive.isTrue + .and(useCan.isRefund.isFalse) + .and(useCanCalculate.recipientCreatorId.eq(creatorId)) + .and( + useCan.canUsage.eq(CanUsage.DONATION) + .or(useCan.canUsage.eq(CanUsage.SPIN_ROULETTE)) + .or(useCan.canUsage.eq(CanUsage.LIVE)) + ) + .and(useCan.createdAt.between(lastMonday, lastSunday)) + ) + .offset(0) + .limit(limit) + .groupBy(member.id) + .orderBy(donationCan.desc(), member.id.desc()) + .fetch() + } +} + +data class DonationRankingProjection @QueryProjection constructor( + @JsonProperty("memberId") val memberId: Long, + @JsonProperty("nickname") val nickname: String, + @JsonProperty("profileImage") val profileImage: String, + @JsonProperty("donationCan") val donationCan: Int +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt new file mode 100644 index 00000000..cbf51577 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/CreatorDonationRankingService.kt @@ -0,0 +1,53 @@ +package kr.co.vividnext.sodalive.explorer.profile + +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingListResponse +import kr.co.vividnext.sodalive.explorer.MemberDonationRankingResponse +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Service +import java.time.DayOfWeek +import java.time.Duration +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.temporal.ChronoUnit +import java.time.temporal.TemporalAdjusters + +@Service +class CreatorDonationRankingService( + private val repository: CreatorDonationRankingQueryRepository, + private val redisTemplate: RedisTemplate +) { + fun getMemberDonationRanking( + creatorId: Long, + limit: Long, + withDonationCan: Boolean + ): List { + val cacheKey = "creator_donation_ranking:$creatorId:$limit:$withDonationCan" + val cachedData = redisTemplate.opsForValue().get(cacheKey) as? MemberDonationRankingListResponse + if (cachedData != null) { + return cachedData.rankings + } + + val memberDonationRanking = repository.getMemberDonationRanking(creatorId, limit) + + val result = memberDonationRanking.map { + MemberDonationRankingResponse( + it.memberId, + it.nickname, + it.profileImage, + if (withDonationCan) it.donationCan else 0 + ) + } + + val now = LocalDateTime.now() + val nextMonday = now.with(TemporalAdjusters.next(DayOfWeek.MONDAY)).with(LocalTime.MIN) + val secondsUntilNextMonday = ChronoUnit.SECONDS.between(now, nextMonday) + + redisTemplate.opsForValue().set( + cacheKey, + MemberDonationRankingListResponse(result), + Duration.ofSeconds(secondsUntilNextMonday) + ) + + return result + } +} -- 2.49.1 From 5ba5edb25cf7248caf2f7218437006e43cc84dd2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 29 Dec 2025 17:16:38 +0900 Subject: [PATCH 89/90] =?UTF-8?q?=EC=9D=BC=EB=B3=B8=EC=96=B4=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/i18n/SodaMessageSource.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 71e4ad23..cc0178ee 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -8,7 +8,7 @@ class SodaMessageSource { "common.error.unknown" to mapOf( Lang.KO to "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", Lang.EN to "An unknown error occurred. Please try again.", - Lang.JA to "不明なエラーが発生しました。もう一度やり直してください。" + Lang.JA to "不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" ), "common.error.access_denied" to mapOf( Lang.KO to "권한이 없습니다.", @@ -38,7 +38,7 @@ class SodaMessageSource { "common.error.invalid_request" to mapOf( Lang.KO to "잘못된 요청입니다.", Lang.EN to "Invalid request.", - Lang.JA to "不正なリクエストです。" + Lang.JA to "無効なリクエストです。" ), "chat.error.room_not_found" to mapOf( Lang.KO to "채팅방을 찾을 수 없습니다.", @@ -76,7 +76,7 @@ class SodaMessageSource { "content.error.minimum_price" to mapOf( Lang.KO to "콘텐츠의 최소금액은 5캔 입니다.", Lang.EN to "The minimum price for content is 5 cans.", - Lang.JA to "コンテンツの最低価格は5缶です。" + Lang.JA to "コンテンツの最低価格は5CANです。" ), "content.error.preview_time_format" to mapOf( Lang.KO to "미리 듣기 시간 형식은 00:30:00 과 같아야 합니다", @@ -196,7 +196,7 @@ class SodaMessageSource { "content.donation.error.minimum_can" to mapOf( Lang.KO to "1캔 이상 후원하실 수 있습니다.", Lang.EN to "You can donate at least 1 can.", - Lang.JA to "1缶以上寄付できます。" + Lang.JA to "1CAN以上支援できます。" ), "content.donation.error.comment_required" to mapOf( Lang.KO to "함께 보낼 메시지를 입력하세요.", @@ -286,7 +286,7 @@ class SodaMessageSource { "category.error.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", Lang.EN to "Invalid access.", - Lang.JA to "不正なアクセスです。" + Lang.JA to "無効なアクセスです。" ), "category.error.title_min_length" to mapOf( Lang.KO to "카테고리명은 2글자 이상 입력하세요", @@ -535,7 +535,7 @@ class SodaMessageSource { "can.coupon.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes found.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "can.coupon.validity_after_current" to mapOf( Lang.KO to "유효기간은 기존 유효기간 이후 날짜로 설정하실 수 있습니다.", @@ -834,7 +834,7 @@ class SodaMessageSource { "admin.content.series.genre.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes to update.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ) ) @@ -886,7 +886,7 @@ class SodaMessageSource { "admin.explorer.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes to update.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "admin.explorer.section_not_found" to mapOf( Lang.KO to "해당하는 섹션이 없습니다.", @@ -947,12 +947,12 @@ class SodaMessageSource { "admin.signature_can.created" to mapOf( Lang.KO to "등록되었습니다.", Lang.EN to "Successfully registered.", - Lang.JA to "登録されました。" + Lang.JA to "投稿しました。" ), "admin.signature_can.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes to update.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "admin.signature_can.updated" to mapOf( Lang.KO to "수정되었습니다.", @@ -1039,7 +1039,7 @@ class SodaMessageSource { "admin.point.policy.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", Lang.EN to "Invalid access.", - Lang.JA to "不正なアクセスです。" + Lang.JA to "無効なアクセスです。" ) ) @@ -1047,7 +1047,7 @@ class SodaMessageSource { "admin.member.statistics.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", Lang.EN to "Invalid access.", - Lang.JA to "不正なアクセスです。" + Lang.JA to "無効なアクセスです。" ) ) @@ -1274,7 +1274,7 @@ class SodaMessageSource { "member.social.google_login_failed" to mapOf( Lang.KO to "구글 로그인을 하지 못했습니다. 다시 시도해 주세요", Lang.EN to "Google login failed. Please try again.", - Lang.JA to "Googleログインに失敗しました。もう一度お試しください。" + Lang.JA to "Googleでログインできませんでした。恐れ入りますが、もう一度お試しください。" ), "member.social.kakao_login_failed" to mapOf( Lang.KO to "카카오 로그인을 하지 못했습니다. 다시 시도해 주세요", @@ -1419,7 +1419,7 @@ class SodaMessageSource { "live.tag.registered" to mapOf( Lang.KO to "등록되었습니다.", Lang.EN to "Successfully registered.", - Lang.JA to "登録されました。" + Lang.JA to "投稿しました。" ), "live.tag.deleted" to mapOf( Lang.KO to "삭제되었습니다.", @@ -1522,7 +1522,7 @@ class SodaMessageSource { "live.room.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "There are no changes.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "live.room.info_not_found" to mapOf( Lang.KO to "해당하는 라이브의 정보가 없습니다.", @@ -1557,7 +1557,7 @@ class SodaMessageSource { "live.room.datetime_format" to mapOf( Lang.KO to "yyyy년 MM월 dd일 (E) a hh시 mm분", Lang.EN to "yyyy MMM dd (EEE) h:mm a", - Lang.JA to "yyyy年 MM月 dd日 (E) a hh時 mm分" + Lang.JA to "yyyy年 MM月 dd日 (E) a hh:mm" ), "live.room.datetime_format_detail" to mapOf( Lang.KO to "yyyy.MM.dd E hh:mm a", @@ -1605,7 +1605,7 @@ class SodaMessageSource { "live.room.menu.blank_not_allowed" to mapOf( Lang.KO to "메뉴판은 빈칸일 수 없습니다.", Lang.EN to "Menu cannot be blank.", - Lang.JA to "メニューボードは空欄にできません。" + Lang.JA to "メニューを入力してください。" ) ) @@ -1707,7 +1707,7 @@ class SodaMessageSource { "explorer.date.live_room.datetime_format" to mapOf( Lang.KO to "yyyy년 MM월 dd일 (E) a hh시 mm분", Lang.EN to "EEE, MMM dd, yyyy h:mm a", - Lang.JA to "yyyy年MM月dd日(E) a hh時 mm分" + Lang.JA to "yyyy年 MM月 dd日 (E) a hh:mm" ), "explorer.date.cheers.format" to mapOf( Lang.KO to "yyyy.MM.dd E hh:mm a", @@ -1720,7 +1720,7 @@ class SodaMessageSource { "explorer.cheers.created" to mapOf( Lang.KO to "등록되었습니다.", Lang.EN to "Registered.", - Lang.JA to "登録されました。" + Lang.JA to "投稿しました。" ), "explorer.cheers.updated" to mapOf( Lang.KO to "수정되었습니다.", @@ -1777,7 +1777,7 @@ class SodaMessageSource { "creator.admin.signature.created" to mapOf( Lang.KO to "등록되었습니다.", Lang.EN to "Successfully registered.", - Lang.JA to "登録されました。" + Lang.JA to "投稿しました。" ), "creator.admin.signature.updated" to mapOf( Lang.KO to "수정되었습니다.", @@ -1792,7 +1792,7 @@ class SodaMessageSource { "creator.admin.signature.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes to update.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "creator.admin.signature.min_can" to mapOf( Lang.KO to "1캔 이상 설정할 수 있습니다.", @@ -1807,12 +1807,12 @@ class SodaMessageSource { "creator.admin.signature.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", Lang.EN to "Invalid access.", - Lang.JA to "不正なアクセスです。" + Lang.JA to "無効なアクセスです。" ), "creator.admin.signature.invalid_request" to mapOf( Lang.KO to "잘못된 요청입니다.", Lang.EN to "Invalid request.", - Lang.JA to "不正なリクエストです。" + Lang.JA to "無効なリクエストです。" ) ) @@ -1830,7 +1830,7 @@ class SodaMessageSource { "creator.admin.content.min_price" to mapOf( Lang.KO to "콘텐츠의 최소금액은 5캔 입니다.", Lang.EN to "Minimum price for content is 5 cans.", - Lang.JA to "コンテンツの最低価格は5缶です。" + Lang.JA to "コンテンツの最低価格は5CANです。" ) ) @@ -1873,12 +1873,12 @@ class SodaMessageSource { "creator.admin.series.no_changes" to mapOf( Lang.KO to "변경사항이 없습니다.", Lang.EN to "No changes to update.", - Lang.JA to "変更事項がありません。" + Lang.JA to "変更データがありません。" ), "creator.admin.series.invalid_access" to mapOf( Lang.KO to "잘못된 접근입니다.", Lang.EN to "Invalid access.", - Lang.JA to "不正なアクセスです。" + Lang.JA to "無効なアクセスです。" ), "creator.admin.series.no_content_added" to mapOf( Lang.KO to "추가된 콘텐츠가 없습니다.", @@ -2115,7 +2115,7 @@ class SodaMessageSource { "chat.room.create_failed_retry" to mapOf( Lang.KO to "채팅방 생성에 실패했습니다. 다시 시도해 주세요.", Lang.EN to "Failed to create the chat room. Please try again.", - Lang.JA to "チャットルームの作成に失敗しました。もう一度お試しください。" + Lang.JA to "チャットルームの作成に失敗しました。恐れ入りますが、もう一度お試しください。" ), "chat.error.retry" to mapOf( Lang.KO to "오류가 발생했습니다. 다시 시도해 주세요.", -- 2.49.1 From 4274375d7cebd7d330c34d8ca282ba3207a6c594 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Dec 2025 16:15:14 +0900 Subject: [PATCH 90/90] =?UTF-8?q?=EC=98=81=EB=AC=B8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/i18n/SodaMessageSource.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index cc0178ee..9664345e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -7,7 +7,7 @@ class SodaMessageSource { private val commonMessages = mapOf( "common.error.unknown" to mapOf( Lang.KO to "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", - Lang.EN to "An unknown error occurred. Please try again.", + Lang.EN to "An unknown error occurred. try again.", Lang.JA to "不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。" ), "common.error.access_denied" to mapOf( @@ -306,7 +306,7 @@ class SodaMessageSource { private val auditionMessages = mapOf( "admin.audition.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", - Lang.EN to "Invalid request. Please try again.", + Lang.EN to "Invalid request.\ntry again.", Lang.JA to "不正なリクエストです。もう一度やり直してください。" ), "admin.audition.status_cannot_revert" to mapOf( @@ -378,7 +378,7 @@ class SodaMessageSource { private val auditionClientMessages = mapOf( "audition.error.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", - Lang.EN to "Invalid request.\nPlease try again.", + Lang.EN to "Invalid request.\ntry again.", Lang.JA to "不正なリクエストです。\nもう一度お試しください。" ) ) @@ -587,7 +587,7 @@ class SodaMessageSource { private val canPaymentMessages = mapOf( "can.payment.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", - Lang.EN to "Invalid request.\nPlease try again.", + Lang.EN to "Invalid request.\ntry again.", Lang.JA to "不正なリクエストです。\nもう一度お試しください。" ), "can.payment.invalid_reservation" to mapOf( @@ -1089,7 +1089,7 @@ class SodaMessageSource { ), "message.error.already_kept" to mapOf( Lang.KO to "이미 보관된 메시지 입니다.", - Lang.EN to "Message is already kept.", + Lang.EN to "This message is already archived.", Lang.JA to "すでに保管されたメッセージです。" ) ) @@ -1115,7 +1115,7 @@ class SodaMessageSource { private val reportMessages = mapOf( "report.received" to mapOf( Lang.KO to "신고가 접수되었습니다.", - Lang.EN to "Your report has been received.", + Lang.EN to "Your report has been submitted.", Lang.JA to "通報が受け付けられました。" ) ) @@ -1273,7 +1273,7 @@ class SodaMessageSource { private val memberSocialMessages = mapOf( "member.social.google_login_failed" to mapOf( Lang.KO to "구글 로그인을 하지 못했습니다. 다시 시도해 주세요", - Lang.EN to "Google login failed. Please try again.", + Lang.EN to "Google sign-in failed. Please try again.", Lang.JA to "Googleでログインできませんでした。恐れ入りますが、もう一度お試しください。" ), "member.social.kakao_login_failed" to mapOf( @@ -1327,7 +1327,7 @@ class SodaMessageSource { private val liveReservationMessages = mapOf( "live.reservation.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", - Lang.EN to "Invalid request.\nPlease try again.", + Lang.EN to "Invalid request.\ntry again.", Lang.JA to "不正なリクエストです。\nもう一度やり直してください。" ), "live.reservation.already_reserved" to mapOf( @@ -1928,7 +1928,7 @@ class SodaMessageSource { ), "chat.character.comment.reported" to mapOf( Lang.KO to "신고가 접수되었습니다.", - Lang.EN to "Your report has been received.", + Lang.EN to "Your report has been submitted.", Lang.JA to "通報が受け付けられました。" ), "chat.character.comment.invalid" to mapOf( @@ -2172,7 +2172,7 @@ class SodaMessageSource { ), "creator.community.invalid_request_retry" to mapOf( Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", - Lang.EN to "Invalid request.\nPlease try again.", + Lang.EN to "Invalid request.\ntry again.", Lang.JA to "不正なリクエストです。\nもう一度お試しください。" ), "creator.community.invalid_post_retry" to mapOf( -- 2.49.1