Compare commits

..

30 Commits

Author SHA1 Message Date
2355aa7c75 AI 캐릭터 리스트에 번역 데이터 제공 기능 추가 2025-12-12 01:32:02 +09:00
5bdb6d20a5 번역 - 지원되지 않는 언어이면 API를 호출하지 않고 빈 값을 반환하도록 수정 2025-12-12 01:00:41 +09:00
143ba2fbb2 HomeApi - languageCode에 따라 콘텐츠, 캐릭터의 번역 데이터를 제공하도록 수정 2025-12-11 23:58:17 +09:00
28fbdd7826 getDetail에 languageCode를 optional로 변경하여 languageCode가 없어도 정상 조회 되도록 수정 2025-12-11 22:33:26 +09:00
25169aaac3 getDetail에 @Transactional 을 추가하여 데이터 저장이 가능하도록 수정 2025-12-11 22:14:18 +09:00
608898eb0c Add translation support for audio content detail 2025-12-11 22:00:30 +09:00
1748b26318 파파고 번역 시 내용을 합쳐서 한번에 처리하지 않고 개별로 API를 호출해서 번역 처리 2025-12-11 19:35:05 +09:00
3ff38bb73a 파파고 번역 시 내용을 분리할 DELIMITER 변경 2025-12-11 18:57:46 +09:00
4498af4509 Fix AI character translation unique constraint column 2025-12-11 18:19:10 +09:00
8636a8cac0 캐릭터 번역 캐시 및 응답 필드 추가 2025-12-11 17:19:00 +09:00
304c001a27 파파고 번역 API 연동 2025-12-11 16:34:22 +09:00
fdac55ebdf AGENTS.md 파일 삭제 2025-12-11 15:50:52 +09:00
668d4f28cd AGENTS.md 파일에 AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙) 상세하게 추가 2025-12-10 00:22:53 +09:00
7b0644cb66 AGENTS.md 파일 추가 2025-12-09 14:56:14 +09:00
503802bcce feat(chat-character): 캐릭터 상세 조회 시 언어 코드 추가 2025-11-26 11:53:40 +09:00
899f2865b3 feat(chat-character): 캐릭터 등록시 파파고 언어 감지 API를 호출하여 languageCode를 기록하는 기능 추가 2025-11-26 11:40:58 +09:00
e0dcbd16fc feat(character-comment): 캐릭터 댓글의 답글 조회시 결과에 언어 코드 추가 2025-11-25 19:35:59 +09:00
62ec994069 feat(character-comment): 캐릭터 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:12:50 +09:00
8ec6d50dd8 feat(content-comment): 콘텐츠 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:10:36 +09:00
ddd46d585e feat(creator-cheers): 팬 Talk 응원글 조회시 결과에 언어 코드 추가 2025-11-25 18:05:08 +09:00
c5fa260a0d feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:42:26 +09:00
412c52e754 feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:36:39 +09:00
8f4544ad71 refactor(lang-detect): LanguageDetectEvent ID 필드를 단일 id로 통합
- LanguageDetectEvent의 contentId/commentId를 제거하고 공통 id(Long) 필드로 단순화
- LanguageDetectListener에서 targetType에 따라 id를 AudioContent/AudioContentComment/CharacterComment 조회에 사용하도록 수정
- AudioContentService, AudioContentCommentService, AudioContentDonationService, CharacterCommentService 등 이벤트 발행부를 새 시그니처(id + targetType)로 정리
2025-11-25 16:32:29 +09:00
619ceeea24 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:19:08 +09:00
a2998002e5 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:10:20 +09:00
da9b89a6cf feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:03:52 +09:00
5ee5107364 feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 15:54:01 +09:00
ae2c699748 refactor(LanguageDetectEvent): 언어 감지 요청 이벤트 클래스명 수정
- AudioContentLanguageDetectEvent -> LanguageDetectEvent
2025-11-25 15:42:32 +09:00
93ccb666c4 feat(content): 콘텐츠 업로드 후 languageCode가 null이면 naver papago 언어 감지 API 호출 기능 추가 2025-11-25 15:11:27 +09:00
edaea84a5b feat(content): 콘텐츠 업로드 request, 상세 조회 response에 languageCode 추가
- CreateAudioContentRequest, GetAudioContentDetailResponse
2025-11-24 12:31:49 +09:00
38 changed files with 1599 additions and 51 deletions

View File

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

View File

@@ -17,6 +17,7 @@ class HomeController(private val service: HomeService) {
@GetMapping
fun fetchData(
@RequestParam timezone: String,
@RequestParam(required = false) languageCode: String? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
@@ -24,6 +25,7 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.fetchData(
timezone = timezone,
languageCode = languageCode,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
@@ -34,6 +36,7 @@ class HomeController(private val service: HomeService) {
@GetMapping("/latest-content")
fun getLatestContentByTheme(
@RequestParam("theme") theme: String,
@RequestParam(required = false) languageCode: String? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
@@ -41,6 +44,7 @@ class HomeController(private val service: HomeService) {
ApiResponse.ok(
service.getLatestContentByTheme(
theme = theme,
languageCode = languageCode,
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member
@@ -70,13 +74,15 @@ class HomeController(private val service: HomeService) {
fun getRecommendContents(
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@RequestParam("contentType", required = false) contentType: ContentType? = null,
@RequestParam(required = false) languageCode: String? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
service.getRecommendContentList(
isAdultContentVisible = isAdultContentVisible ?: true,
contentType = contentType ?: ContentType.ALL,
member = member
member = member,
languageCode = languageCode
)
)
}

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
@@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationServic
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
@@ -47,6 +49,9 @@ class HomeService(
private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -57,6 +62,7 @@ class HomeService(
fun fetchData(
timezone: String,
languageCode: String?,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
@@ -111,6 +117,37 @@ class HomeService(
}
}
/**
* latestContentList 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 latestContentList의 title을 번역 데이터로 변경한다
*/
val translatedLatestContentList = if (!languageCode.isNullOrBlank()) {
val contentIds = latestContentList.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
latestContentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
latestContentList
}
} else {
latestContentList
}
val eventBannerList = GetEventResponse(
totalCount = 0,
eventList = emptyList()
@@ -140,6 +177,38 @@ class HomeService(
// 인기 캐릭터 조회
val popularCharacters = characterService.getPopularCharacters()
/**
* popularCharacters 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름을 번역 데이터로 변경한다
*/
val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) {
val characterIds = popularCharacters.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
popularCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
popularCharacters
}
} else {
popularCharacters
}
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
.withHour(15)
@@ -159,12 +228,81 @@ class HomeService(
sort = ContentRankingSortType.REVENUE
)
/**
* contentRanking 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 contentRanking title을 번역 데이터로 변경한다
*/
val translatedContentRanking = if (!languageCode.isNullOrBlank()) {
val contentIds = contentRanking.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
contentRanking.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentRanking
}
} else {
contentRanking
}
val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId,
isAdult = isAdult,
contentType = contentType
)
/**
* recommendChannelList의 콘텐츠 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
*/
val translatedRecommendChannelList = if (!languageCode.isNullOrBlank()) {
val contentIds = recommendChannelList
.flatMap { it.contentList }
.map { it.contentId }
.distinct()
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
recommendChannelList.map { channel ->
val translatedContentList = channel.contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
channel.copy(contentList = translatedContentList)
}
} else {
recommendChannelList
}
} else {
recommendChannelList
}
val freeContentList = contentService.getLatestContentByTheme(
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
@@ -183,6 +321,37 @@ class HomeService(
}
}
/**
* freeContentList 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 freeContentList title을 번역 데이터로 변경한다
*/
val translatedFreeContentList = if (!languageCode.isNullOrBlank()) {
val contentIds = freeContentList.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
freeContentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
freeContentList
}
} else {
freeContentList
}
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = contentService.getLatestContentByTheme(
theme = emptyList(),
@@ -199,6 +368,37 @@ class HomeService(
}
}
/**
* pointAvailableContentList 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 pointAvailableContentList title을 번역 데이터로 변경한다
*/
val translatedPointAvailableContentList = if (!languageCode.isNullOrBlank()) {
val contentIds = pointAvailableContentList.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
pointAvailableContentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
pointAvailableContentList
}
} else {
pointAvailableContentList
}
val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
@@ -210,21 +410,22 @@ class HomeService(
liveList = liveList,
creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList,
latestContentList = translatedLatestContentList,
bannerList = bannerList,
eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList,
popularCharacters = popularCharacters,
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
pointAvailableContentList = pointAvailableContentList,
popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking,
recommendChannelList = translatedRecommendChannelList,
freeContentList = translatedFreeContentList,
pointAvailableContentList = translatedPointAvailableContentList,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
member = member
member = member,
languageCode = languageCode
),
curationList = curationList
)
@@ -232,6 +433,7 @@ class HomeService(
fun getLatestContentByTheme(
theme: String,
languageCode: String?,
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
@@ -249,7 +451,7 @@ class HomeService(
listOf(theme)
}
return contentService.getLatestContentByTheme(
val contentList = contentService.getLatestContentByTheme(
theme = themeList,
contentType = contentType,
isFree = false,
@@ -261,6 +463,39 @@ class HomeService(
true
}
}
/**
* contentList 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 contentList title을 번역 데이터로 변경한다
*/
val translatedContentList = if (!languageCode.isNullOrBlank()) {
val contentIds = contentList.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentList
}
} else {
contentList
}
return translatedContentList
}
fun getDayOfWeekSeriesList(
@@ -336,7 +571,8 @@ class HomeService(
fun getRecommendContentList(
isAdultContentVisible: Boolean,
contentType: ContentType,
member: Member?
member: Member?,
languageCode: String? = null
): List<AudioContentMainItem> {
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
@@ -371,6 +607,37 @@ class HomeService(
}
}
return result
/**
* 추천 콘텐츠 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 result의 title을 번역 데이터로 변경한다
*/
val translatedResult = if (!languageCode.isNullOrBlank()) {
val contentIds = result.map { it.contentId }
if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
.associateBy { it.contentId }
result.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
result
}
} else {
result
}
return translatedResult
}
}

View File

@@ -22,6 +22,8 @@ class ChatCharacter(
// 캐릭터 한 줄 소개
var description: String,
var languageCode: String? = null,
// AI 시스템 프롬프트
@Column(columnDefinition = "TEXT", nullable = false)
var systemPrompt: String,

View File

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

View File

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

View File

@@ -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 컨테이너

View File

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

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
@@ -10,11 +11,20 @@ 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.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
@@ -24,6 +34,7 @@ import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import kotlin.collections.map
@RestController
@RequestMapping("/api/chat/character")
@@ -32,13 +43,17 @@ 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
) {
@GetMapping("/main")
fun getCharacterMain(
@RequestParam(required = false) languageCode: String? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<CharacterMainResponse> = run {
// 배너 조회 (최대 10개)
@@ -65,15 +80,110 @@ class ChatCharacterController(
}
}
/**
* 최근 대화한 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 recentCharacters 캐릭터 이름을 번역 데이터로 변경한다
*/
val translatedRecentCharacters = if (!languageCode.isNullOrBlank()) {
val characterIds = recentCharacters.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
recentCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
if (translatedName.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName)
}
}
} else {
recentCharacters
}
} else {
recentCharacters
}
// 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters()
/**
* popularCharacters 캐릭터 이름과 description 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 popularCharacters의 캐릭터 이름과 description을 번역 데이터로 변경한다
*/
val translatedPopularCharacters = if (!languageCode.isNullOrBlank()) {
val characterIds = popularCharacters.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
popularCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
popularCharacters
}
} else {
popularCharacters
}
// 최근 등록된 캐릭터 리스트 조회
val newCharacters = service.getRecentCharactersPage(
page = 0,
size = 50
).content
/**
* 최근 등록된 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 newCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다
*/
val translatedNewCharacters = if (!languageCode.isNullOrBlank()) {
val characterIds = newCharacters.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
newCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
newCharacters
}
} else {
newCharacters
}
// 추천 캐릭터 조회
// 최근 대화한 캐릭터를 제외한 랜덤 30개 조회
// Controller에서는 호출만
@@ -81,6 +191,38 @@ class ChatCharacterController(
val excludeIds = recentCharacters.map { it.characterId }
val recommendCharacters = service.getRecommendCharacters(excludeIds, 30)
/**
* 추천 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 recommendCharacters 캐릭터 이름과 description을 번역 데이터로 변경한다
*/
val translatedRecommendCharacters = if (!languageCode.isNullOrBlank()) {
val characterIds = recommendCharacters.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
recommendCharacters.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
recommendCharacters
}
} else {
recommendCharacters
}
// 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터)
val curationSections = curationQueryService.getActiveCurationsWithCharacters()
.map { agg ->
@@ -103,10 +245,10 @@ class ChatCharacterController(
ApiResponse.ok(
CharacterMainResponse(
banners = banners,
recentCharacters = recentCharacters,
popularCharacters = popularCharacters,
newCharacters = newCharacters,
recommendCharacters = recommendCharacters,
recentCharacters = translatedRecentCharacters,
popularCharacters = translatedPopularCharacters,
newCharacters = translatedNewCharacters,
recommendCharacters = translatedRecommendCharacters,
curationSections = curationSections
)
)
@@ -119,6 +261,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 +291,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 ->
@@ -162,6 +421,40 @@ class ChatCharacterController(
)
}
/**
* 다른 캐릭터 이름, 태그 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
*/
val translatedOthers = if (!languageCode.isNullOrBlank()) {
val characterIds = others.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
others.map { other ->
val payload = translations[other.characterId]?.renderedPayload
val translatedName = payload?.name
val translatedTags = payload?.tags
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
other
} else {
other.copy(name = translatedName, tags = translatedTags)
}
}
} else {
others
}
} else {
others
}
// 최신 댓글 1개 조회
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
@@ -171,6 +464,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 +475,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 +489,53 @@ class ChatCharacterController(
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
*/
@GetMapping("/recent")
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
ApiResponse.ok(
service.getRecentCharactersPage(
page = page ?: 0,
size = 20
)
fun getRecentCharacters(
@RequestParam(required = false) languageCode: String? = null,
@RequestParam("page", required = false) page: Int?
): ApiResponse<RecentCharactersResponse> = run {
val characterPage = service.getRecentCharactersPage(
page = page ?: 0,
size = 20
)
/**
* 최근 등록된 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다
*/
val translatedContent = if (!languageCode.isNullOrBlank()) {
val characterIds = characterPage.content.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
characterPage.content.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
characterPage.content
}
} else {
characterPage.content
}
val translatedCharacterPage = RecentCharactersResponse(
totalCount = characterPage.totalCount,
content = translatedContent
)
ApiResponse.ok(translatedCharacterPage)
}
/**
@@ -210,6 +545,7 @@ class ChatCharacterController(
*/
@GetMapping("/recommend")
fun getRecommendCharacters(
@RequestParam(required = false) languageCode: String? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
val recent = if (member == null || member.auth == null) {
@@ -219,6 +555,40 @@ class ChatCharacterController(
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
.map { it.characterId }
}
ApiResponse.ok(service.getRecommendCharacters(recent, 20))
val characterList = service.getRecommendCharacters(recent, 20)
/**
* 추천 캐릭터 이름 번역 데이터 조회
*
* languageCode != null
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
*
* 한 번에 조회하고 characterId 매핑하여 characterList 캐릭터 이름과 description을 번역 데이터로 변경한다
*/
val translatedCharacterList = if (!languageCode.isNullOrBlank()) {
val characterIds = characterList.map { it.characterId }
if (characterIds.isNotEmpty()) {
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = languageCode)
.associateBy { it.characterId }
characterList.map { character ->
val translatedName = translations[character.characterId]?.renderedPayload?.name
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
character
} else {
character.copy(name = translatedName, description = translatedDesc)
}
}
} else {
characterList
}
} else {
characterList
}
ApiResponse.ok(translatedCharacterList)
}
}

View File

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

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 = ["characterId", "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,9 @@
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?
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
}

View File

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

View File

@@ -131,6 +131,7 @@ class AudioContentController(private val service: AudioContentService) {
fun getDetail(
@PathVariable id: Long,
@RequestParam timezone: String,
@RequestParam(required = false) languageCode: String? = null,
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
@@ -141,7 +142,8 @@ class AudioContentController(private val service: AudioContentService) {
id = id,
member = member,
isAdultContentVisible = isAdultContentVisible ?: true,
timezone = timezone
timezone = timezone,
languageCode = languageCode
)
)
}

View File

@@ -21,10 +21,14 @@ import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -56,6 +60,9 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val audioContentCloudFront: AudioContentCloudFront,
@@ -238,6 +245,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 +339,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!!)
}
@@ -477,11 +503,13 @@ class AudioContentService(
}
}
@Transactional
fun getDetail(
id: Long,
member: Member,
isAdultContentVisible: Boolean,
timezone: String
timezone: String,
languageCode: String?
): GetAudioContentDetailResponse {
val isAdult = member.auth != null && isAdultContentVisible
@@ -699,10 +727,93 @@ class AudioContentService(
listOf()
}
var translated: TranslatedContent? = null
/**
* audioContent.languageCode != languageCode
*
* 번역 콘텐츠를 조회한다. - contentId, locale
* 번역 콘텐츠가 있으면
* TranslatedContent로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, detail, tags
*
* 파파고로 번역한 데이터를 TranslatedContent로 가공한다
*/
if (
audioContent.languageCode != null &&
audioContent.languageCode!!.isNotBlank() &&
!languageCode.isNullOrBlank() &&
audioContent.languageCode != languageCode
) {
val locale = languageCode.lowercase()
val existing = contentTranslationRepository
.findByContentIdAndLocale(audioContent.id!!, locale)
if (existing != null) {
val payload = existing.renderedPayload
translated = TranslatedContent(
title = payload.title,
detail = payload.detail,
tags = payload.tags
)
} else {
val texts = mutableListOf<String>()
texts.add(audioContent.title)
texts.add(audioContent.detail)
texts.add(tag)
val sourceLanguage = audioContent.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedDetail = translatedTexts[index++]
val translatedTags = translatedTexts[index]
val payload = kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
contentTranslationRepository.save(
kr.co.vividnext.sodalive.content.translation.ContentTranslation(
contentId = audioContent.id!!,
locale = locale,
translatedTitle = translatedTitle,
renderedPayload = payload
)
)
translated = TranslatedContent(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
}
}
}
return GetAudioContentDetailResponse(
contentId = audioContent.id!!,
title = audioContent.title,
detail = contentDetail,
languageCode = audioContent.languageCode,
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
contentUrl = audioContentUrl,
themeStr = audioContent.theme!!.theme,
@@ -745,7 +856,8 @@ class AudioContentService(
previousContent = previousContent,
nextContent = nextContent,
buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable
isAvailableUsePoint = audioContent.isPointAvailable,
translated = translated
)
}

View File

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

View File

@@ -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<ContentBuyer>,
val isAvailableUsePoint: Boolean
val isAvailableUsePoint: Boolean,
val translated: TranslatedContent?
)
data class OtherContentResponse @QueryProjection constructor(

View File

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

View File

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

View File

@@ -32,7 +32,8 @@ class AudioContentCommentController(
audioContentId = request.contentId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member
member = member,
languageCode = request.languageCode
)
try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
package kr.co.vividnext.sodalive.content.translation
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(columnNames = ["contentId", "locale"])
]
)
class ContentTranslation(
val contentId: Long,
val locale: String,
val translatedTitle: String,
@Column(columnDefinition = "json")
@Convert(converter = ContentTranslationPayloadConverter::class)
val renderedPayload: ContentTranslationPayload
) : BaseEntity()
data class ContentTranslationPayload(
val title: String,
val detail: String,
val tags: String
)
@Converter(autoApply = false)
class ContentTranslationPayloadConverter : AttributeConverter<ContentTranslationPayload, String> {
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?
)

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.content.translation
import org.springframework.data.jpa.repository.JpaRepository
interface ContentTranslationRepository : JpaRepository<ContentTranslation, Long> {
fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation?
fun findByContentIdInAndLocale(contentIds: List<Long>, locale: String): List<ContentTranslation>
}

View File

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

View File

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

View File

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

View File

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

View File

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

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,84 @@
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() || 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<String>()
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,
PapagoTranslationResponse::class.java
)
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"
)
}
}

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

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

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