Compare commits
21 Commits
main
...
8636a8cac0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8636a8cac0 | |||
| 304c001a27 | |||
| fdac55ebdf | |||
| 668d4f28cd | |||
| 7b0644cb66 | |||
| 503802bcce | |||
| 899f2865b3 | |||
| e0dcbd16fc | |||
| 62ec994069 | |||
| 8ec6d50dd8 | |||
| ddd46d585e | |||
| c5fa260a0d | |||
| 412c52e754 | |||
| 8f4544ad71 | |||
| 619ceeea24 | |||
| a2998002e5 | |||
| da9b89a6cf | |||
| 5ee5107364 | |||
| ae2c699748 | |||
| 93ccb666c4 | |||
| edaea84a5b |
@@ -13,8 +13,11 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
@@ -40,6 +43,7 @@ class AdminChatCharacterController(
|
||||
private val adminService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
@@ -165,6 +169,18 @@ class AdminChatCharacterController(
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||
}
|
||||
|
||||
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
||||
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = chatCharacter.id!!,
|
||||
query = chatCharacter.description,
|
||||
targetType = LanguageDetectTargetType.CHARACTER
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class ChatCharacter(
|
||||
// 캐릭터 한 줄 소개
|
||||
var description: String,
|
||||
|
||||
var languageCode: String? = null,
|
||||
|
||||
// AI 시스템 프롬프트
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
var systemPrompt: String,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,7 +47,7 @@ class CharacterCommentController(
|
||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
||||
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
|
||||
ApiResponse.ok(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 컨테이너
|
||||
|
||||
@@ -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 {
|
||||
@@ -40,7 +44,8 @@ class CharacterCommentService(
|
||||
memberNickname = member.nickname,
|
||||
createdAt = toEpochMilli(entity.createdAt),
|
||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||
comment = entity.comment
|
||||
comment = entity.comment,
|
||||
languageCode = entity.languageCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,25 +57,44 @@ class CharacterCommentService(
|
||||
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 {
|
||||
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
commentRepository.save(entity)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 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 {
|
||||
fun addReply(
|
||||
characterId: Long,
|
||||
parentCommentId: Long,
|
||||
member: Member,
|
||||
text: String,
|
||||
languageCode: String? = null
|
||||
): Long {
|
||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||
@@ -78,11 +102,23 @@ class CharacterCommentService(
|
||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||
|
||||
val entity = CharacterComment(comment = text)
|
||||
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||
entity.chatCharacter = character
|
||||
entity.member = member
|
||||
entity.parent = parent
|
||||
commentRepository.save(entity)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = entity.id!!,
|
||||
query = text,
|
||||
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return entity.id!!
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.controller
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||
@@ -12,9 +13,17 @@ import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
@@ -32,7 +41,10 @@ class ChatCharacterController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val chatRoomService: ChatRoomService,
|
||||
private val characterCommentService: CharacterCommentService,
|
||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
||||
private val curationQueryService: CharacterCurationQueryService,
|
||||
|
||||
private val translationService: PapagoTranslationService,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
@@ -119,6 +131,7 @@ class ChatCharacterController(
|
||||
@GetMapping("/{characterId}")
|
||||
fun getCharacterDetail(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(required = false, defaultValue = "ko") languageCode: String? = "ko",
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
@@ -148,6 +161,122 @@ class ChatCharacterController(
|
||||
)
|
||||
}
|
||||
|
||||
var translated: TranslatedAiCharacterDetail? = null
|
||||
if (!languageCode.isNullOrBlank() && languageCode != character.languageCode) {
|
||||
val locale = languageCode.lowercase()
|
||||
|
||||
val existing = aiCharacterTranslationRepository
|
||||
.findByCharacterIdAndLocale(character.id!!, locale)
|
||||
|
||||
if (existing != null) {
|
||||
val payload = existing.renderedPayload
|
||||
translated = TranslatedAiCharacterDetail(
|
||||
name = payload.name,
|
||||
description = payload.description,
|
||||
gender = payload.gender,
|
||||
personality = TranslatedAiCharacterPersonality(
|
||||
trait = payload.personalityTrait,
|
||||
description = payload.personalityDescription
|
||||
).takeIf {
|
||||
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||
},
|
||||
background = TranslatedAiCharacterBackground(
|
||||
topic = payload.backgroundTopic,
|
||||
description = payload.backgroundDescription
|
||||
).takeIf {
|
||||
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||
},
|
||||
tags = payload.tags
|
||||
)
|
||||
} else {
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(character.name)
|
||||
texts.add(character.description)
|
||||
texts.add(character.gender ?: "")
|
||||
|
||||
val hasPersonality = personality != null
|
||||
if (hasPersonality) {
|
||||
texts.add(personality!!.trait)
|
||||
texts.add(personality.description)
|
||||
}
|
||||
|
||||
val hasBackground = background != null
|
||||
if (hasBackground) {
|
||||
texts.add(background!!.topic)
|
||||
texts.add(background.description)
|
||||
}
|
||||
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = character.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedName = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedGender = translatedTexts[index++]
|
||||
|
||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||
if (hasPersonality) {
|
||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||
trait = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||
if (hasBackground) {
|
||||
translatedBackground = TranslatedAiCharacterBackground(
|
||||
topic = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personalityTrait = translatedPersonality?.trait ?: "",
|
||||
personalityDescription = translatedPersonality?.description ?: "",
|
||||
backgroundTopic = translatedBackground?.topic ?: "",
|
||||
backgroundDescription = translatedBackground?.description ?: "",
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val entity = AiCharacterTranslation(
|
||||
characterId = character.id!!,
|
||||
locale = locale,
|
||||
translatedName = translatedName,
|
||||
translatedTags = translatedTags,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
aiCharacterTranslationRepository.save(entity)
|
||||
|
||||
translated = TranslatedAiCharacterDetail(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personality = translatedPersonality,
|
||||
background = translatedBackground,
|
||||
tags = translatedTags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||
.map { other ->
|
||||
@@ -171,6 +300,7 @@ class ChatCharacterController(
|
||||
characterId = character.id!!,
|
||||
name = character.name,
|
||||
description = character.description,
|
||||
languageCode = character.languageCode,
|
||||
mbti = character.mbti,
|
||||
gender = character.gender,
|
||||
age = character.age,
|
||||
@@ -183,7 +313,8 @@ class ChatCharacterController(
|
||||
characterType = character.characterType,
|
||||
others = others,
|
||||
latestComment = latestComment,
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
||||
translated = translated
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<OtherCharacter>,
|
||||
val latestComment: CharacterCommentResponse?,
|
||||
val totalComments: Int
|
||||
val totalComments: Int,
|
||||
val translated: TranslatedAiCharacterDetail?
|
||||
)
|
||||
|
||||
data class OtherCharacter(
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.translate
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.AttributeConverter
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Convert
|
||||
import javax.persistence.Converter
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["character_id", "locale"])
|
||||
]
|
||||
)
|
||||
class AiCharacterTranslation(
|
||||
val characterId: Long,
|
||||
val locale: String,
|
||||
val translatedName: String,
|
||||
val translatedTags: String,
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
||||
val renderedPayload: AiCharacterTranslationRenderedPayload
|
||||
) : BaseEntity()
|
||||
|
||||
data class AiCharacterTranslationRenderedPayload(
|
||||
val name: String,
|
||||
val description: String,
|
||||
val gender: String,
|
||||
val personalityTrait: String,
|
||||
val personalityDescription: String,
|
||||
val backgroundTopic: String,
|
||||
val backgroundDescription: String,
|
||||
val tags: String
|
||||
)
|
||||
|
||||
@Converter(autoApply = false)
|
||||
class AiCharacterTranslationRenderedPayloadConverter :
|
||||
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
|
||||
|
||||
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?
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.chat.character.translate
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
|
||||
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -238,6 +238,7 @@ class AudioContentService(
|
||||
val audioContent = AudioContent(
|
||||
title = request.title.trim(),
|
||||
detail = request.detail.trim(),
|
||||
languageCode = request.languageCode,
|
||||
price = if (request.price > 0) {
|
||||
request.price
|
||||
} else {
|
||||
@@ -331,6 +332,24 @@ class AudioContentService(
|
||||
|
||||
audioContent.content = contentPath
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (audioContent.languageCode.isNullOrBlank()) {
|
||||
val papagoQuery = listOf(
|
||||
request.title.trim(),
|
||||
request.detail.trim(),
|
||||
request.tags.trim()
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = audioContent.id!!,
|
||||
query = papagoQuery
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||
}
|
||||
|
||||
@@ -703,6 +722,7 @@ class AudioContentService(
|
||||
contentId = audioContent.id!!,
|
||||
title = audioContent.title,
|
||||
detail = contentDetail,
|
||||
languageCode = audioContent.languageCode,
|
||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||
contentUrl = audioContentUrl,
|
||||
themeStr = audioContent.theme!!.theme,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ data class GetAudioContentDetailResponse(
|
||||
val contentId: Long,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val languageCode: String?,
|
||||
val coverImageUrl: String,
|
||||
val contentUrl: String,
|
||||
val themeStr: String,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package kr.co.vividnext.sodalive.content
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.event.TransactionPhase
|
||||
import org.springframework.transaction.event.TransactionalEventListener
|
||||
import org.springframework.util.LinkedMultiValueMap
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
/**
|
||||
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||
*/
|
||||
enum class LanguageDetectTargetType {
|
||||
CONTENT,
|
||||
COMMENT,
|
||||
CHARACTER,
|
||||
CHARACTER_COMMENT,
|
||||
CREATOR_CHEERS
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) {
|
||||
val characterId = event.id
|
||||
|
||||
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
||||
if (character == null) {
|
||||
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
||||
return
|
||||
}
|
||||
|
||||
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||
if (!character.languageCode.isNullOrBlank()) {
|
||||
log.debug(
|
||||
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
||||
characterId,
|
||||
character.languageCode
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
|
||||
|
||||
character.languageCode = langCode
|
||||
chatCharacterRepository.save(character)
|
||||
|
||||
log.info(
|
||||
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
||||
characterId,
|
||||
langCode
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleContentLanguageDetect(event: LanguageDetectEvent) {
|
||||
val contentId = event.id
|
||||
|
||||
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)
|
||||
|
||||
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 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<String, String>().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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,8 @@ class AudioContentCommentController(
|
||||
audioContentId = request.contentId,
|
||||
parentId = request.parentId,
|
||||
isSecret = request.isSecret,
|
||||
member = member
|
||||
member = member,
|
||||
languageCode = request.languageCode
|
||||
)
|
||||
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
@@ -32,7 +34,8 @@ class AudioContentCommentService(
|
||||
comment: String,
|
||||
audioContentId: Long,
|
||||
parentId: Long? = null,
|
||||
isSecret: Boolean = false
|
||||
isSecret: Boolean = false,
|
||||
languageCode: String?
|
||||
): Long {
|
||||
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
|
||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
||||
@@ -50,7 +53,7 @@ class AudioContentCommentService(
|
||||
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
|
||||
}
|
||||
|
||||
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
|
||||
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
|
||||
audioContentComment.audioContent = audioContent
|
||||
audioContentComment.member = member
|
||||
|
||||
@@ -85,6 +88,17 @@ class AudioContentCommentService(
|
||||
)
|
||||
)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = savedContentComment.id!!,
|
||||
query = comment,
|
||||
targetType = LanguageDetectTargetType.COMMENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return savedContentComment.id!!
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -4,9 +4,12 @@ import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
|
||||
class AudioContentDonationService(
|
||||
private val canPaymentService: CanPaymentService,
|
||||
private val queryRepository: AudioContentRepository,
|
||||
private val commentRepository: AudioContentCommentRepository
|
||||
private val commentRepository: AudioContentCommentRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
@Transactional
|
||||
fun donation(request: AudioContentDonationRequest, member: Member) {
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,7 @@ class ExplorerQueryRepository(
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
content = it.cheers,
|
||||
languageCode = it.languageCode,
|
||||
date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||
replyList = it.children.asSequence()
|
||||
.map { cheers ->
|
||||
@@ -505,6 +506,7 @@ class ExplorerQueryRepository(
|
||||
"$cloudFrontHost/profile/default-profile.png"
|
||||
},
|
||||
content = cheers.cheers,
|
||||
languageCode = cheers.languageCode,
|
||||
date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||
replyList = listOf()
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.explorer
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.content.SortType
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse
|
||||
@@ -441,7 +443,7 @@ class ExplorerService(
|
||||
val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
|
||||
if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.")
|
||||
|
||||
val cheers = CreatorCheers(cheers = request.content)
|
||||
val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode)
|
||||
cheers.member = member
|
||||
cheers.creator = creator
|
||||
|
||||
@@ -456,6 +458,17 @@ class ExplorerService(
|
||||
}
|
||||
|
||||
cheersRepository.save(cheers)
|
||||
|
||||
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||
if (request.languageCode.isNullOrBlank()) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = cheers.id!!,
|
||||
query = request.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_CHEERS
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCreatorProfileCheers(
|
||||
|
||||
@@ -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<GetCheersResponseItem>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
/**
|
||||
* Papago 번역 API 응답 예시
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "message": {
|
||||
* "result": {
|
||||
* "srcLangType": "ko",
|
||||
* "tarLangType": "en",
|
||||
* "translatedText": "Hello, I like to eat apple while riding a bicycle."
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* 위 JSON 구조에 대응하는 최상위 응답 모델
|
||||
*/
|
||||
data class PapagoTranslationResponse(
|
||||
val message: Message
|
||||
) {
|
||||
/**
|
||||
* message 필드 내부 구조
|
||||
*/
|
||||
data class Message(
|
||||
val result: Result
|
||||
)
|
||||
|
||||
/**
|
||||
* 실제 번역 결과 데이터
|
||||
*/
|
||||
data class Result(
|
||||
val srcLangType: String,
|
||||
val tarLangType: String,
|
||||
val translatedText: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.client.RestTemplate
|
||||
|
||||
@Service
|
||||
class PapagoTranslationService(
|
||||
@Value("\${cloud.naver.papago-client-id}")
|
||||
private val papagoClientId: String,
|
||||
|
||||
@Value("\${cloud.naver.papago-client-secret}")
|
||||
private val papagoClientSecret: String
|
||||
) {
|
||||
private val restTemplate: RestTemplate = RestTemplate()
|
||||
|
||||
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
|
||||
|
||||
fun translate(request: TranslateRequest): TranslateResult {
|
||||
if (request.texts.isEmpty()) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
|
||||
validateLanguages(request.sourceLanguage, request.targetLanguage)
|
||||
|
||||
val headers = HttpHeaders().apply {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
|
||||
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
|
||||
}
|
||||
|
||||
val chunks = chunkTexts(request.texts)
|
||||
val translatedTexts = mutableListOf<String>()
|
||||
|
||||
chunks.forEach { chunk ->
|
||||
val textsInChunkCount = chunk.split(S3P_DELIMITER).size
|
||||
|
||||
try {
|
||||
val body = mapOf(
|
||||
"source" to request.sourceLanguage,
|
||||
"target" to request.targetLanguage,
|
||||
"text" to chunk
|
||||
)
|
||||
|
||||
val requestEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.postForEntity(
|
||||
papagoTranslateUrl,
|
||||
requestEntity,
|
||||
PapagoTranslationResponse::class.java
|
||||
)
|
||||
|
||||
if (!response.statusCode.is2xxSuccessful) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val translated = response.body?.message?.result?.translatedText
|
||||
if (translated.isNullOrBlank()) {
|
||||
repeat(textsInChunkCount) { translatedTexts.add("") }
|
||||
} else {
|
||||
translated.split(S3P_DELIMITER).forEach { translatedTexts.add(it) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
repeat(textsInChunkCount) { translatedTexts.add("") }
|
||||
}
|
||||
}
|
||||
|
||||
return TranslateResult(translatedTexts)
|
||||
}
|
||||
|
||||
private fun validateLanguages(sourceLanguage: String, targetLanguage: String) {
|
||||
requireSupportedLanguage(sourceLanguage)
|
||||
requireSupportedLanguage(targetLanguage)
|
||||
}
|
||||
|
||||
private fun requireSupportedLanguage(language: String) {
|
||||
val normalized = language.lowercase()
|
||||
if (!SUPPORTED_LANGUAGE_CODES.contains(normalized)) {
|
||||
throw IllegalArgumentException("지원하지 않는 언어 코드입니다: $language")
|
||||
}
|
||||
}
|
||||
|
||||
private fun chunkTexts(texts: List<String>): List<String> {
|
||||
if (texts.isEmpty()) return emptyList()
|
||||
val chunks = mutableListOf<String>()
|
||||
var startIndex = 0
|
||||
while (startIndex < texts.size) {
|
||||
var endIndex = texts.size
|
||||
while (endIndex > startIndex) {
|
||||
val candidate = texts.subList(startIndex, endIndex)
|
||||
val joined = candidate.joinToString(S3P_DELIMITER)
|
||||
if (joined.length <= MAX_TEXT_LENGTH || endIndex - startIndex == 1) {
|
||||
chunks.add(joined)
|
||||
startIndex = endIndex
|
||||
break
|
||||
}
|
||||
endIndex--
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SUPPORTED_LANGUAGE_CODES = setOf(
|
||||
"ko",
|
||||
"en",
|
||||
"ja",
|
||||
"zh-cn",
|
||||
"zh-tw",
|
||||
"es",
|
||||
"fr",
|
||||
"vi",
|
||||
"th",
|
||||
"id",
|
||||
"de",
|
||||
"ru",
|
||||
"pt",
|
||||
"it"
|
||||
)
|
||||
|
||||
private const val S3P_DELIMITER = "__S3P__"
|
||||
private const val MAX_TEXT_LENGTH = 3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
data class TranslateRequest(
|
||||
val texts: List<String>,
|
||||
val sourceLanguage: String,
|
||||
val targetLanguage: String
|
||||
)
|
||||
|
||||
data class TranslateResult(
|
||||
val translatedText: List<String>
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
71
work/scripts/check-commit-message-rules.sh
Executable file
71
work/scripts/check-commit-message-rules.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user