Compare commits

...

5 Commits

8 changed files with 480 additions and 3 deletions

View File

@@ -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 ->
@@ -184,7 +313,8 @@ class ChatCharacterController(
characterType = character.characterType,
others = others,
latestComment = latestComment,
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
translated = translated
)
)
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
data class CharacterDetailResponse(
val characterId: Long,
@@ -20,7 +21,8 @@ data class CharacterDetailResponse(
val characterType: CharacterType,
val others: List<OtherCharacter>,
val latestComment: CharacterCommentResponse?,
val totalComments: Int
val totalComments: Int,
val translated: TranslatedAiCharacterDetail?
)
data class OtherCharacter(

View File

@@ -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?
)

View File

@@ -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?
}

View File

@@ -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
)
}

View File

@@ -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
}
}

View File

@@ -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>
)

View 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