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/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" } } 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 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/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/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 dfa8c3a2..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 @@ -13,8 +13,13 @@ 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.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.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -40,6 +45,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, @@ -118,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 호출 @@ -165,6 +171,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) } @@ -215,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") } } @@ -239,7 +261,7 @@ class AdminChatCharacterController( metadata = metadata ) } catch (e: Exception) { - throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + throw SodaException(messageKey = "admin.chat.character.image_save_failed") } } @@ -279,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") } } @@ -315,6 +337,13 @@ class AdminChatCharacterController( request = request ) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.id, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 if (request.originalWorkId != null) { // 서비스에서 유효성 검증 및 저장까지 처리 @@ -413,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 e02e6320..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 @@ -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,14 +29,16 @@ 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 ) { /** 원작 등록 (중복 제목 방지 포함) */ @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, @@ -56,14 +63,51 @@ 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 } /** 원작 수정 (이미지 경로 포함 선택적 변경) */ @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 } @@ -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) } @@ -114,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) } @@ -123,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) } @@ -132,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") } } /** 원작 페이징 조회 */ @@ -153,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 { @@ -175,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 } @@ -187,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 } @@ -198,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/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 00e62373..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 @@ -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,17 +42,26 @@ 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) { - 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/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/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/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index d66d1f8f..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 @@ -1,7 +1,9 @@ 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 import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.ContentType @@ -10,10 +12,13 @@ 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 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 @@ -47,6 +52,12 @@ class HomeService( private val rankingRepository: RankingRepository, private val explorerQueryRepository: ExplorerQueryRepository, + private val contentTranslationRepository: ContentTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + private val seriesTranslationRepository: SeriesTranslationRepository, + + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -111,6 +122,8 @@ class HomeService( } } + val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList) + val eventBannerList = GetEventResponse( totalCount = 0, eventList = emptyList() @@ -122,23 +135,28 @@ 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 popularCharacters = characterService.getPopularCharacters() + val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) val currentDateTime = LocalDateTime.now() val startDate = currentDateTime @@ -159,12 +177,64 @@ class HomeService( sort = ContentRankingSortType.REVENUE ) + val contentRankingContentIds = contentRanking.map { it.contentId } + val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code) + .associateBy { it.contentId } + + contentRanking.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + contentRanking + } + val recommendChannelList = recommendChannelService.getRecommendChannel( memberId = memberId, isAdult = isAdult, contentType = contentType ) + /** + * recommendChannelList의 콘텐츠 번역 데이터 조회 + * + * languageCode != null + * contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale + * + * 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다 + */ + val channelContentIds = recommendChannelList + .flatMap { it.contentList } + .map { it.contentId } + .distinct() + + 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) + } + } + + channel.copy(contentList = translatedContentList) + } + } else { + recommendChannelList + } + val freeContentList = contentService.getLatestContentByTheme( theme = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, @@ -183,6 +253,8 @@ class HomeService( } } + val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) + // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = contentService.getLatestContentByTheme( theme = emptyList(), @@ -199,6 +271,8 @@ class HomeService( } } + val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) + val curationList = curationService.getContentCurationList( tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 isAdult = isAdult, @@ -210,17 +284,17 @@ class HomeService( liveList = liveList, creatorRanking = creatorRanking, latestContentThemeList = latestContentThemeList, - latestContentList = latestContentList, + latestContentList = translatedLatestContentList, bannerList = bannerList, eventBannerList = eventBannerList, - originalAudioDramaList = originalAudioDramaList, + originalAudioDramaList = translatedOriginalAudioDramaList, auditionList = auditionList, - dayOfWeekSeriesList = dayOfWeekSeriesList, - popularCharacters = popularCharacters, - contentRanking = contentRanking, - recommendChannelList = recommendChannelList, - freeContentList = freeContentList, - pointAvailableContentList = pointAvailableContentList, + dayOfWeekSeriesList = translatedDayOfWeekSeriesList, + popularCharacters = translatedPopularCharacters, + contentRanking = translatedContentRanking, + recommendChannelList = translatedRecommendChannelList, + freeContentList = translatedFreeContentList, + pointAvailableContentList = translatedPointAvailableContentList, recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, @@ -249,7 +323,7 @@ class HomeService( listOf(theme) } - return contentService.getLatestContentByTheme( + val contentList = contentService.getLatestContentByTheme( theme = themeList, contentType = contentType, isFree = false, @@ -261,6 +335,8 @@ class HomeService( true } } + + return getTranslatedContentList(contentList = contentList) } fun getDayOfWeekSeriesList( @@ -272,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( @@ -371,6 +449,114 @@ class HomeService( } } - return result + return getTranslatedContentList(contentList = result) + } + + /** + * 콘텐츠 리스트의 제목을 현재 언어(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 } + + 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 { + contentList + } + } + + /** + * 시리즈 리스트의 제목을 현재 언어(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)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 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 + } } } 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/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/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/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..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,11 +47,11 @@ 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) + 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/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index d35f2cb2..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 @@ -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 @@ -20,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 단건(목록 원소) @@ -35,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 25d186a4..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 @@ -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 { @@ -32,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!!, @@ -40,49 +44,84 @@ 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 ) } 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!!, memberProfileImage = profileUrl(imageHost, member.profileImage), memberNickname = member.nickname, createdAt = toEpochMilli(entity.createdAt), - comment = entity.comment + comment = entity.comment, + languageCode = entity.languageCode ) } @Transactional - fun addComment(characterId: Long, member: Member, text: String): Long { - val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } - if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") - if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long { + 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) + val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character entity.member = member commentRepository.save(entity) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = entity.id!!, + query = text, + targetType = LanguageDetectTargetType.CHARACTER_COMMENT + ) + ) + } + return entity.id!! } @Transactional - fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): 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("댓글 내용을 입력해주세요.") + fun addReply( + characterId: Long, + parentCommentId: Long, + member: Member, + text: String, + languageCode: String? = null + ): Long { + 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) + val entity = CharacterComment(comment = text, languageCode = languageCode) entity.chatCharacter = character entity.member = member entity.parent = parent commentRepository.save(entity) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = entity.id!!, + query = text, + targetType = LanguageDetectTargetType.CHARACTER_COMMENT + ) + ) + } + return entity.id!! } @@ -126,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) { @@ -171,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 adfb1efe..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 @@ -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 @@ -10,11 +11,21 @@ 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 +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.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.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest @@ -32,7 +43,12 @@ 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, + + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -65,6 +81,24 @@ class ChatCharacterController( } } + val characterIds = recentCharacters.map { it.characterId } + val translatedRecentCharacters = if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .associateBy { it.characterId } + + recentCharacters.map { character -> + val translatedName = translations[character.characterId]?.renderedPayload?.name + if (translatedName.isNullOrBlank()) { + character + } else { + character.copy(name = translatedName) + } + } + } else { + recentCharacters + } + // 인기 캐릭터 조회 val popularCharacters = service.getPopularCharacters() @@ -103,10 +137,10 @@ class ChatCharacterController( ApiResponse.ok( CharacterMainResponse( banners = banners, - recentCharacters = recentCharacters, - popularCharacters = popularCharacters, - newCharacters = newCharacters, - recommendCharacters = recommendCharacters, + recentCharacters = translatedRecentCharacters, + popularCharacters = getTranslatedAiCharacterList(popularCharacters), + newCharacters = getTranslatedAiCharacterList(newCharacters), + recommendCharacters = getTranslatedAiCharacterList(recommendCharacters), curationSections = curationSections ) ) @@ -121,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 @@ -148,6 +182,118 @@ class ChatCharacterController( ) } + var translated: TranslatedAiCharacterDetail? = null + if (langContext.lang.code != character.languageCode) { + val existing = aiCharacterTranslationRepository + .findByCharacterIdAndLocale(character.id!!, langContext.lang.code) + + 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 = langContext.lang.code + ) + ) + + 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 = langContext.lang.code, + 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 -> @@ -162,6 +308,35 @@ class ChatCharacterController( ) } + /** + * 다른 캐릭터 이름, 태그 번역 데이터 조회 + * + * languageCode != null + * aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale + * + * 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다 + */ + val characterIds = others.map { it.characterId } + val translatedOthers = if (characterIds.isNotEmpty()) { + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) + .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 + } + // 최신 댓글 1개 조회 val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!) @@ -171,6 +346,7 @@ class ChatCharacterController( characterId = character.id!!, name = character.name, description = character.description, + languageCode = character.languageCode, mbti = character.mbti, gender = character.gender, age = character.age, @@ -181,9 +357,10 @@ class ChatCharacterController( originalTitle = character.originalTitle, originalLink = character.originalLink, characterType = character.characterType, - others = others, + others = translatedOthers, latestComment = latestComment, - totalComments = characterCommentService.getTotalCommentCount(character.id!!) + totalComments = characterCommentService.getTotalCommentCount(character.id!!), + translated = translated ) ) } @@ -194,13 +371,20 @@ 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("page", required = false) page: Int? + ): ApiResponse = run { + val characterPage = service.getRecentCharactersPage( + page = page ?: 0, + size = 20 ) + + val translatedCharacterPage = RecentCharactersResponse( + totalCount = characterPage.totalCount, + content = getTranslatedAiCharacterList(characterPage.content) + ) + + ApiResponse.ok(translatedCharacterPage) } /** @@ -219,6 +403,48 @@ class ChatCharacterController( .listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려 .map { it.characterId } } - ApiResponse.ok(service.getRecommendCharacters(recent, 20)) + + ApiResponse.ok( + getTranslatedAiCharacterList( + service.getRecommendCharacters( + recent, + 20 + ) + ) + ) + } + + /** + * 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 + } } } 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..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,11 +2,13 @@ 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, val name: String, val description: String, + val languageCode: String?, val mbti: String?, val gender: String?, val age: Int?, @@ -19,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/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/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt new file mode 100644 index 00000000..3b1e7fc2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -0,0 +1,87 @@ +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 = ["characterId", "locale"]) + ] +) +class AiCharacterTranslation( + val characterId: Long, + val locale: String, + + @Column(columnDefinition = "json") + @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) + var 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..112998b3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslationRepository.kt @@ -0,0 +1,9 @@ +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? + + fun findByCharacterIdInAndLocale(characterIds: List, locale: String): List +} 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/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index eb8cbfb6..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 @@ -3,12 +3,16 @@ 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 +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 +34,12 @@ class OriginalWorkController( private val queryService: OriginalWorkQueryService, private val characterImageRepository: CharacterImageRepository, + 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 ) { @@ -51,7 +61,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 + ) + ) } /** @@ -66,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 @@ -83,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/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/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 new file mode 100644 index 00000000..d5d7b9ce --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt @@ -0,0 +1,102 @@ +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 +) + +data class TranslatedOriginalWork( + 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..0e88a4ab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt @@ -0,0 +1,9 @@ +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? + + fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set, locale: String): List +} 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 abd21c44..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 @@ -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,9 @@ 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.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -50,6 +54,9 @@ class ChatRoomService( private val messageRepository: ChatMessageRepository, 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, private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, @@ -72,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) { @@ -119,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) @@ -220,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") } } @@ -259,7 +266,7 @@ class ChatRoomService( } } else { if (latest?.message.isNullOrBlank() && latest?.characterImage != null) { - "[이미지]" + messageSource.getMessage("chat.room.last_message_image", langContext.lang).orEmpty() } else { "" } @@ -270,10 +277,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, @@ -287,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() } @@ -299,11 +326,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) } @@ -311,7 +336,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) 기본 방 기준 참여/활성 여부 확인 @@ -325,10 +350,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) { @@ -338,9 +363,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") } ) } @@ -351,10 +376,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( @@ -495,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 @@ -574,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) } @@ -586,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) { @@ -621,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!!) @@ -818,7 +842,7 @@ class ChatRoomService( } } log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts) - throw SodaException("메시지 전송을 실패했습니다.") + throw SodaException(messageKey = "chat.message.send_failed") } private fun callExternalApiForChatSend( @@ -860,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 } @@ -888,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/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/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/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/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 dfb4a0d2..8c1eb9d9 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,21 @@ 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 +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.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 +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,11 +67,19 @@ 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, private val applicationEventPublisher: ApplicationEventPublisher, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + @Value("\${cloud.aws.s3.content-bucket}") private val audioContentBucket: String, @@ -100,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 @@ -160,12 +179,19 @@ class AudioContentService( audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) } + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) } @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 @@ -179,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) @@ -198,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 @@ -238,6 +264,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 { @@ -331,6 +358,31 @@ 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( + LanguageDetectEvent( + id = audioContent.id!!, + query = papagoQuery + ) + ) + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = audioContent.id!!, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + } + return CreateAudioContentResponse(contentId = audioContent.id!!) } @@ -338,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") } } } @@ -395,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 @@ -406,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, @@ -421,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, @@ -433,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, @@ -455,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, @@ -467,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, @@ -477,6 +529,7 @@ class AudioContentService( } } + @Transactional fun getDetail( id: Long, member: Member, @@ -487,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, @@ -506,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( @@ -544,7 +599,7 @@ class AudioContentService( audioContent.releaseDate != null && audioContent.releaseDate!! < LocalDateTime.now() ) { - throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.") + throw SodaException(messageKey = "content.error.invalid_content_retry") } // 댓글 @@ -577,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 } @@ -699,13 +756,108 @@ 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() && + audioContent.languageCode != langContext.lang.code + ) { + val existing = contentTranslationRepository + .findByContentIdAndLocale(audioContent.id!!, langContext.lang.code) + + 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 = langContext.lang.code + ) + ) + + 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 + ) + + contentTranslationRepository.save( + ContentTranslation( + contentId = audioContent.id!!, + locale = langContext.lang.code, + renderedPayload = payload + ) + ) + + translated = TranslatedContent( + title = translatedTitle, + detail = translatedDetail, + tags = translatedTags + ) + } + } + } + + /** + * themeStr 번역 처리 + */ + val themeStrTranslated = run { + val theme = audioContent.theme + if (theme?.id != null) { + val locale = langContext.lang.code + val translatedContentTheme = contentThemeTranslationRepository + .findByContentThemeIdAndLocale(theme.id!!, locale) + val text = translatedContentTheme?.theme + if (!text.isNullOrBlank()) text else theme.theme + } else { + audioContent.theme!!.theme + } + } + return GetAudioContentDetailResponse( contentId = audioContent.id!!, title = audioContent.title, detail = contentDetail, + languageCode = audioContent.languageCode, coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}", contentUrl = audioContentUrl, - themeStr = audioContent.theme!!.theme, + themeStr = themeStrTranslated, tag = tag, price = audioContent.price, duration = audioContent.duration ?: "", @@ -745,7 +897,8 @@ class AudioContentService( previousContent = previousContent, nextContent = nextContent, buyerList = buyerList, - isAvailableUsePoint = audioContent.isPointAvailable + isAvailableUsePoint = audioContent.isPointAvailable, + translated = translated ) } @@ -773,12 +926,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, @@ -852,9 +1027,60 @@ class AudioContentService( it } + val contentIds = items.map { it.contentId } + val translatedContentList = if (contentIds.isNotEmpty()) { + val translations = contentTranslationRepository + .findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) + .associateBy { it.contentId } + + items.map { item -> + val translatedTitle = translations[item.contentId]?.renderedPayload?.title + if (translatedTitle.isNullOrBlank()) { + item + } else { + item.copy(title = translatedTitle) + } + } + } else { + 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 = items + items = themeTranslatedList ) } @@ -894,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( @@ -906,7 +1137,7 @@ class AudioContentService( contentType = contentType, offset = offset, limit = limit, - sortType = sortType + sortType = normalizedSortType ) return GetAudioContentRanking( @@ -917,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( @@ -956,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!!, @@ -994,8 +1228,20 @@ class AudioContentService( orderByRandom: Boolean = false, isPointAvailableOnly: Boolean = false ): List { - return repository.getLatestContentByTheme( - theme = theme, + /** + * - AS-IS theme은 한글만 처리하도록 되어 있음 + * - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리 + */ + val normalizedTheme = normalizeThemeForQuery( + themes = theme, + contentType = contentType, + isFree = isFree, + isAdult = isAdult, + isPointAvailableOnly = isPointAvailableOnly + ) + + val contentList = repository.getLatestContentByTheme( + theme = normalizedTheme, contentType = contentType, offset = offset, limit = limit, @@ -1005,5 +1251,103 @@ class AudioContentService( orderByRandom = orderByRandom, isPointAvailableOnly = isPointAvailableOnly ) + + val contentIds = contentList.map { it.contentId } + 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 { + 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() + } + + 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/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..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,11 +3,13 @@ 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, val title: String, val detail: String, + val languageCode: String?, val coverImageUrl: String, val contentUrl: String, val themeStr: String, @@ -39,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/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt new file mode 100644 index 00000000..6d125a20 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -0,0 +1,418 @@ +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 +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 +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 + +/** + * 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트. + */ +enum class LanguageDetectTargetType { + CONTENT, + COMMENT, + CHARACTER, + CHARACTER_COMMENT, + CREATOR_CHEERS, + SERIES, + ORIGINAL_WORK, + + CREATOR_CONTENT_CATEGORY +} + +class LanguageDetectEvent( + val id: Long, + val query: String, + val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT +) + +data class PapagoLanguageDetectResponse( + val langCode: String? +) + +@Component +class LanguageDetectListener( + private val audioContentRepository: AudioContentRepository, + private val audioContentCommentRepository: AudioContentCommentRepository, + private val chatCharacterRepository: ChatCharacterRepository, + private val characterCommentRepository: CharacterCommentRepository, + private val creatorCheersRepository: CreatorCheersRepository, + private val seriesRepository: ContentSeriesRepository, + private val originalWorkRepository: OriginalWorkRepository, + private val categoryRepository: CategoryRepository, + + private val applicationEventPublisher: ApplicationEventPublisher, + + @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(LanguageDetectListener::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: LanguageDetectEvent) { + if (event.query.isBlank()) { + log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event) + return + } + + 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) + LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) + LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event) + LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(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) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = characterId, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}", + characterId, + langCode + ) + } + + private fun handleContentLanguageDetect(event: LanguageDetectEvent) { + val contentId = event.id + + val audioContent = audioContentRepository.findById(contentId).orElse(null) + if (audioContent == null) { + log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!audioContent.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}", + contentId, + audioContent.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return + + audioContent.languageCode = langCode + + // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. + audioContentRepository.save(audioContent) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", + contentId, + langCode + ) + } + + private fun handleCommentLanguageDetect(event: LanguageDetectEvent) { + val commentId = event.id + + 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 handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) { + val commentId = event.id + + 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 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 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 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 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 { + 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", 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={}, targetId={}", + response.statusCode, + targetIdForLog + ) + return null + } + + val langCode = response.body?.langCode?.takeIf { it.isNotBlank() } + if (langCode == null) { + log.warn( + "[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}", + targetIdForLog + ) + return null + } + + langCode + } catch (ex: Exception) { + // 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다. + log.error( + "[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}", + targetIdForLog, + ex + ) + null + } + } +} 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/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 00c6c9ae..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 @@ -2,8 +2,16 @@ 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.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 import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,7 +20,12 @@ class CategoryService( private val repository: CategoryRepository, private val contentRepository: AudioContentRepository, private val blockMemberRepository: BlockMemberRepository, - private val categoryContentRepository: CategoryContentRepository + private val categoryContentRepository: CategoryContentRepository, + private val categoryTranslationRepository: CategoryTranslationRepository, + + private val langContext: LangContext, + private val applicationEventPublisher: ApplicationEventPublisher, + private val translationService: PapagoTranslationService ) { @Transactional fun createCategory(request: CreateCategoryRequest, member: Member) { @@ -40,16 +53,31 @@ class CategoryService( ) categoryContent.isActive = true } + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = category.id!!, + query = request.title, + targetType = LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY + ) + ) } @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) category.title = request.title + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.categoryId, + targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY + ) + ) } for (contentId in request.addContentIdList) { @@ -80,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) @@ -97,14 +125,86 @@ 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") - 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) { - 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/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..a99f61d2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/category/CategoryTranslationRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.category + +import org.springframework.data.jpa.repository.JpaRepository + +interface CategoryTranslationRepository : JpaRepository { + fun findByCategoryIdAndLocale(categoryId: Long, locale: String): CategoryTranslation? + + fun findByCategoryIdInAndLocale(categoryIds: Collection, locale: String): List +} 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..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,14 +25,15 @@ 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, audioContentId = request.contentId, parentId = request.parentId, isSecret = request.isSecret, - member = member + member = member, + languageCode = request.languageCode ) try { @@ -61,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)) } @@ -73,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( @@ -92,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/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/AudioContentCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentService.kt index 90b9be77..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 @@ -2,9 +2,13 @@ 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 +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 @@ -22,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 @@ -32,14 +38,17 @@ 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다시 시도해 주세요.") + ?: 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!!, @@ -47,10 +56,10 @@ class AudioContentCommentService( ) if (isSecret && !isExistsAudioContent) { - throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.") + throw SodaException(messageKey = "content.comment.error.secret_requires_purchase") } - val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret) + val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret) audioContentComment.audioContent = audioContent audioContentComment.member = member @@ -75,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, @@ -85,13 +94,24 @@ class AudioContentCommentService( ) ) + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = savedContentComment.id!!, + query = comment, + targetType = LanguageDetectTargetType.COMMENT + ) + ) + } + return savedContentComment.id!! } @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) { @@ -150,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/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, 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/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/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..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 @@ -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,15 +17,16 @@ 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) { - 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!!, @@ -34,10 +38,23 @@ class AudioContentDonationService( val audioContentComment = AudioContentComment( comment = request.comment, + languageCode = request.languageCode, donationCan = request.donationCan ) audioContentComment.audioContent = audioContent audioContentComment.member = member - commentRepository.save(audioContentComment) + + val savedComment = commentRepository.save(audioContentComment) + + // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. + if (request.languageCode.isNullOrBlank()) { + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = savedComment.id!!, + query = request.comment, + targetType = LanguageDetectTargetType.COMMENT + ) + ) + } } } 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/content/main/AudioContentMainService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt index 72a025eb..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 @@ -6,7 +6,11 @@ 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.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 import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import org.springframework.beans.factory.annotation.Value @@ -20,14 +24,33 @@ class AudioContentMainService( private val repository: AudioContentRepository, private val blockMemberRepository: BlockMemberRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository, + private val audioContentThemeService: AudioContentThemeService, + + private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + + private val contentTranslationRepository: ContentTranslationRepository, + + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") 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) @@ -60,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 @@ -70,13 +97,19 @@ class AudioContentMainService( listOf(theme) } + val themeList = normalizeThemeForQuery( + themes = themeListRaw, + contentType = contentType, + isAdult = isAdult + ) + val totalCount = repository.totalCountNewContentFor2Weeks( themeList, memberId = member.id!!, isAdult = isAdult, contentType = contentType ) - val items = repository.findByThemeFor2Weeks( + val contentList = repository.findByThemeFor2Weeks( cloudfrontHost = imageHost, memberId = member.id!!, theme = themeList, @@ -87,7 +120,75 @@ class AudioContentMainService( ) .filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) } - return GetNewContentAllResponse(totalCount, items) + val contentIds = contentList.map { it.contentId } + val translatedContentList = 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 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) 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 3a2cc9bd..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 @@ -6,15 +6,26 @@ 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.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 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 @@ -27,6 +38,13 @@ class ContentSeriesService( private val explorerQueryRepository: ExplorerQueryRepository, private val seriesContentRepository: ContentSeriesContentRepository, + private val langContext: LangContext, + + private val seriesTranslationRepository: SeriesTranslationRepository, + private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, + private val contentTranslationRepository: ContentTranslationRepository, + private val translationService: PapagoTranslationService, + @Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String ) { @@ -42,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( @@ -83,7 +167,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( @@ -112,9 +196,10 @@ 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 fun getSeriesDetail( seriesId: Long, isAdultContentVisible: Boolean, @@ -125,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( @@ -156,7 +241,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 = SeriesTranslationPayload( + title = translatedTitle, + introduction = translatedIntroduction, + keywords = translatedKeywords + ) + + seriesTranslationRepository.save( + 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, @@ -171,6 +364,7 @@ class ContentSeriesService( .withZoneSameInstant(ZoneId.of("Asia/Seoul")) .toLocalDateTime() .format(dateTimeFormatter), + publishedDateUtc = publishedDateUtc, creator = GetSeriesDetailResponse.GetSeriesDetailCreator( creatorId = series.member!!.id!!, nickname = series.member!!.nickname, @@ -186,7 +380,9 @@ class ContentSeriesService( keywordList = keywordList, publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek), contentList = seriesContentList.items, - contentCount = seriesContentList.totalCount + contentCount = seriesContentList.totalCount, + translated = translated, + translatedGenre = translatedGenre ) } @@ -228,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( @@ -243,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( @@ -258,7 +486,7 @@ class ContentSeriesService( isAdult = isAdult, contentType = contentType ) - return seriesToSeriesListItem(seriesList, isAdult, contentType) + return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) } fun getDayOfWeekSeriesList( @@ -288,7 +516,7 @@ class ContentSeriesService( seriesList } - return seriesToSeriesListItem(seriesList, isAdult, contentType) + return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) } private fun seriesToSeriesListItem( @@ -338,27 +566,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) + } } } } 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/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/series/translation/SeriesGenreTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt new file mode 100644 index 00000000..a7f91a07 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt @@ -0,0 +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, + 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..3e487365 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt @@ -0,0 +1,9 @@ +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? + + fun findBySeriesGenreIdInAndLocale(seriesGenreIds: List, locale: String): List +} 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..cc346b25 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslation.kt @@ -0,0 +1,79 @@ +package kr.co.vividnext.sodalive.content.series.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 + +@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: List +) + +@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 = emptyList() + ) + } + // 호환 처리: 과거 스키마에서 keywords가 String 이었을 수 있으므로 유연하게 파싱한다. + return try { + val node = objectMapper.readTree(dbData) + val title = node.get("title")?.asText() ?: "" + val introduction = node.get("introduction")?.asText() ?: "" + val keywordsNode = node.get("keywords") + val keywords: List = 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() + ) + } + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} + +data class TranslatedSeries( + val title: String, + val introduction: String, + val keywords: List +) 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..266bcbc7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt @@ -0,0 +1,8 @@ +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? + fun findBySeriesIdInAndLocale(seriesIds: List, locale: String): List +} 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/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/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..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 @@ -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) @@ -45,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/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..546f0058 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt @@ -0,0 +1,9 @@ +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? + + fun findByContentThemeIdInAndLocale(contentThemeIds: Collection, locale: String): List +} 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..75d7a6b5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt @@ -0,0 +1,63 @@ +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, + + @Column(columnDefinition = "json") + @Convert(converter = ContentTranslationPayloadConverter::class) + var 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..9e59a80a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.content.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface ContentTranslationRepository : JpaRepository { + fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation? + + fun findByContentIdInAndLocale(contentIds: List, locale: String): List +} 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/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 1c97e30b..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 @@ -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, @@ -67,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 @@ -108,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() @@ -152,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 @@ -194,6 +199,13 @@ class CreatorAdminContentService( } audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.id, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) } } } 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/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 eba94690..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 @@ -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, @@ -38,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() @@ -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 @@ -107,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() @@ -143,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() @@ -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 { @@ -204,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) } @@ -230,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() @@ -247,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/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/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/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/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/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 4c1bacd2..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( @@ -488,7 +500,8 @@ class ExplorerQueryRepository( "$cloudFrontHost/profile/default-profile.png" }, content = it.cheers, - date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + languageCode = it.languageCode, + date = date.format(cheersDateFormatter), replyList = it.children.asSequence() .map { cheers -> val replyDate = cheers.createdAt!! @@ -505,7 +518,8 @@ class ExplorerQueryRepository( "$cloudFrontHost/profile/default-profile.png" }, content = cheers.cheers, - date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")), + languageCode = cheers.languageCode, + 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 75362694..3cad7f4e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt @@ -3,19 +3,26 @@ 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.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 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 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 @@ -37,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, @@ -44,6 +53,10 @@ class ExplorerService( private val seriesService: ContentSeriesService, private val applicationEventPublisher: ApplicationEventPublisher, + private val contentTranslationRepository: ContentTranslationRepository, + + private val messageSource: SodaMessageSource, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String @@ -61,15 +74,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 @@ -92,15 +110,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 @@ -114,16 +137,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) @@ -132,8 +157,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) @@ -155,7 +181,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!!) @@ -172,11 +198,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 @@ -188,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 { @@ -230,6 +262,24 @@ class ExplorerService( listOf() } + val contentIds = contentList.map { it.contentId } + val translatedContentList = 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 + } + // 크리에이터의 최신 오디오 콘텐츠 1개 val latestContent = if (isCreator) { audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible) @@ -330,7 +380,7 @@ class ExplorerService( userDonationRanking = memberDonationRanking, similarCreatorList = similarCreatorList, liveRoomList = liveRoomList, - contentList = contentList, + contentList = translatedContentList, latestContent = latestContent, totalContentCount = totalContentCount, ownedContentCount = ownedContentCount, @@ -436,12 +486,18 @@ 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) + val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode) cheers.member = member cheers.creator = creator @@ -456,6 +512,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( @@ -474,7 +541,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) { @@ -508,7 +575,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/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 ) 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/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/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 + } +} 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 ) 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/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 66989598..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 @@ -29,6 +31,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( @@ -44,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, @@ -64,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) } @@ -118,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!! ) ) @@ -129,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) } @@ -243,6 +248,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) { @@ -264,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) @@ -314,6 +328,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) { @@ -336,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) @@ -356,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) @@ -383,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 @@ -494,6 +512,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) { @@ -517,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) @@ -578,6 +605,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, 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/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 + } +} 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..9664345e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -0,0 +1,2294 @@ +package kr.co.vividnext.sodalive.i18n + +import org.springframework.stereotype.Component + +@Component +class SodaMessageSource { + private val commonMessages = mapOf( + "common.error.unknown" to mapOf( + Lang.KO to "알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.", + Lang.EN to "An unknown error occurred. 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.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.", + 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 "チャットルームが見つかりません。" + ) + ) + + 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 "コンテンツの最低価格は5CANです。" + ), + "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 "1CAN以上支援できます。" + ), + "content.donation.error.comment_required" to mapOf( + Lang.KO to "함께 보낼 메시지를 입력하세요.", + Lang.EN to "Please enter a message to send.", + Lang.JA to "一緒に送るメッセージを入力してください。" + ) + ) + + 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 "잘못된 접근입니다.", + 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 "이미 구매하셨습니다", + 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다시 시도해 주세요.", + Lang.EN to "Invalid request.\ntry 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」が登録されました。今すぐオリジナルオーディオドラマのオーディションに応募してみてください!" + ) + ) + + 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 auditionClientMessages = mapOf( + "audition.error.invalid_request_retry" to mapOf( + Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.", + Lang.EN to "Invalid request.\ntry 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 "잘못된 크리에이터 입니다.", + 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 "不正な会員番号です。" + ) + ) + + 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.\ntry 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 "이미지 저장에 실패했습니다.", + 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 "該当の原作が見つかりません。" + ) + ) + + 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 "既に登録されたテーマです。" + ) + ) + + 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 "받는 사람이 없습니다.", + 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 "This message is already archived.", + 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 submitted.", + 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ファイルは有料投稿のみアップロードできます。" + ) + ) + + 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 sign-in 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 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.\ntry 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 "룰렛을 사용할 수 없습니다.", + 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 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 "이메일", + 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 "女性" + ), + "member.gender.unknown" to mapOf( + Lang.KO to "미", + Lang.EN to "Unknown", + Lang.JA to "不明" + ) + ) + + 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 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 "コンテンツの最低価格は5CANです。" + ) + ) + + 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 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 submitted.", + 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 "유료 게시글 등록을 위해서는 이미지가 필요합니다.", + 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.\ntry 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, + contentErrorMessages, + contentNotificationMessages, + contentFormatMessages, + contentRankingMessages, + contentCommentMessages, + contentDonationMessages, + orderMessages, + playlistMessages, + seriesMessages, + seriesBannerMessages, + categoryMessages, + alarmMessages, + auditionMessages, + auditionRequestMessages, + auditionNotificationMessages, + auditionRoleMessages, + auditionClientMessages, + auditionApplicantMessages, + auditionVoteMessages, + settlementRatioMessages, + adminCanMessages, + canChargeMessages, + canChargeEventMessages, + canCouponMessages, + canPaymentMessages, + adminChatBannerMessages, + adminChatCalculateMessages, + adminChatCharacterMessages, + adminChatCurationMessages, + adminChatCharacterImageMessages, + adminChatOriginalWorkMessages, + adminContentMessages, + adminContentBannerMessages, + adminHashTagCurationMessages, + adminContentSeriesMessages, + adminContentSeriesBannerMessages, + adminContentSeriesGenreMessages, + adminContentThemeMessages, + adminEventBannerMessages, + adminChargeEventMessages, + adminExplorerMessages, + adminLiveMessages, + adminSignatureCanMessages, + adminAdMediaPartnerMessages, + adminMemberMessages, + adminMemberTagMessages, + adminPointPolicyMessages, + adminMemberStatisticsMessages, + messageMessages, + noticeMessages, + reportMessages, + imageValidationMessages, + memberAuthMessages, + memberMessages, + memberValidationMessages, + memberSocialMessages, + eventMessages, + faqMessages, + liveRouletteMessages, + liveReservationMessages, + liveTagMessages, + liveRoomMessages, + liveRoomMenuMessages, + memberProviderMessages, + memberGenderMessages, + explorerSectionMessages, + explorerDateMessages, + explorerResponseMessages, + explorerValidationMessages, + explorerAccessMessages, + creatorAdminMemberMessages, + creatorAdminSignatureMessages, + creatorAdminContentMessages, + creatorAdminSeriesRequestMessages, + creatorAdminSeriesMessages, + chatCharacterCommentMessages, + chatCharacterMessages, + chatCharacterImageMessages, + chatCharacterBannerMessages, + chatOriginalWorkMessages, + chatQuotaMessages, + chatRoomQuotaMessages, + chatRoomMessages, + creatorCommunityMessages + ) + for (messages in messageGroups) { + val translations = messages[key] ?: continue + return translations[lang] ?: translations[Lang.KO] + } + return 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 new file mode 100644 index 00000000..99e241ee --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -0,0 +1,506 @@ +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 +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.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 +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 +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, + CONTENT_THEME, + + SERIES, + SERIES_GENRE, + + ORIGINAL_WORK, + + CREATOR_CONTENT_CATEGORY +} + +class LanguageTranslationEvent( + val id: Long, + val targetType: LanguageTranslationTargetType +) + +@Component +class LanguageTranslationListener( + private val audioContentRepository: AudioContentRepository, + private val chatCharacterRepository: ChatCharacterRepository, + 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 categoryRepository: CategoryRepository, + private val categoryTranslationRepository: CategoryTranslationRepository, + + 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) + LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) + LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) + LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) + LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event) + LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageTranslation(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) + } + } + } + } + + 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) + } + } + } + } + + 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) + } + } + } + } + + 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) + } + } + } + } + + 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) + } + } + } + } +} 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..1b01dffa --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -0,0 +1,95 @@ +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 +import org.springframework.web.client.postForEntity + +@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() || request.sourceLanguage == request.targetLanguage) { + return TranslateResult(emptyList()) + } + + if (!validateLanguages(request.sourceLanguage, request.targetLanguage)) { + return TranslateResult(emptyList()) + } + + val headers = HttpHeaders().apply { + contentType = MediaType.APPLICATION_JSON + set("X-NCP-APIGW-API-KEY-ID", papagoClientId) + set("X-NCP-APIGW-API-KEY", papagoClientSecret) + } + + val translatedTexts = mutableListOf() + + request.texts.forEach { text -> + try { + val body = mapOf( + "source" to request.sourceLanguage, + "target" to request.targetLanguage, + "text" to text + ) + + val requestEntity = HttpEntity(body, headers) + + val response = restTemplate.postForEntity( + papagoTranslateUrl, + requestEntity + ) + + if (!response.statusCode.is2xxSuccessful) { + return@forEach + } + + val translated = response.body?.message?.result?.translatedText + translatedTexts.add(translated ?: "") + } catch (_: Exception) { + } + } + + return TranslateResult(translatedTexts) + } + + private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean { + return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage) + } + + private fun requireSupportedLanguage(language: String): Boolean { + val normalized = language.lowercase() + return SUPPORTED_LANGUAGE_CODES.contains(normalized) + } + + companion object { + private val SUPPORTED_LANGUAGE_CODES = setOf( + "ko", + "en", + "ja" + ) + + /** + * 번역 대상 언어 코드 집합을 반환한다. + * + * @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시) + * @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합 + */ + fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set { + val normalized = excludedLanguageCode?.lowercase() + return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet() + } + } +} 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 +) 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) { 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() ) } 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") } } 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") + } } } 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..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") @@ -98,7 +104,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 +113,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 +123,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 +133,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 +143,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 +153,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 +169,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 +194,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 +209,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 +219,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( @@ -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)) } 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") } } 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} 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