채팅 메시지 다국어 분리

This commit is contained in:
2025-12-23 18:38:54 +09:00
parent 6e8a88178c
commit 9d619450ef
15 changed files with 420 additions and 144 deletions

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -18,6 +20,8 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/chat/character") @RequestMapping("/api/chat/character")
class CharacterCommentController( class CharacterCommentController(
private val service: CharacterCommentService, private val service: CharacterCommentService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@@ -28,9 +32,9 @@ class CharacterCommentController(
@RequestBody request: CreateCharacterCommentRequest, @RequestBody request: CreateCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addComment(characterId, member, request.comment) val id = service.addComment(characterId, member, request.comment)
ApiResponse.ok(id) ApiResponse.ok(id)
@@ -43,9 +47,9 @@ class CharacterCommentController(
@RequestBody request: CreateCharacterCommentRequest, @RequestBody request: CreateCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (request.comment.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode) val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
ApiResponse.ok(id) ApiResponse.ok(id)
@@ -58,8 +62,8 @@ class CharacterCommentController(
@RequestParam(required = false) cursor: Long?, @RequestParam(required = false) cursor: Long?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val data = service.listComments(imageHost, characterId, cursor, limit) val data = service.listComments(imageHost, characterId, cursor, limit)
ApiResponse.ok(data) ApiResponse.ok(data)
@@ -73,8 +77,8 @@ class CharacterCommentController(
@RequestParam(required = false) cursor: Long?, @RequestParam(required = false) cursor: Long?,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
// characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨 // characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨
val data = service.getReplies(imageHost, commentId, cursor, limit) val data = service.getReplies(imageHost, commentId, cursor, limit)
@@ -87,10 +91,11 @@ class CharacterCommentController(
@PathVariable commentId: Long, @PathVariable commentId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
service.deleteComment(characterId, commentId, member) service.deleteComment(characterId, commentId, member)
ApiResponse.ok(true, "댓글이 삭제되었습니다.") val message = messageSource.getMessage("chat.character.comment.deleted", langContext.lang)
ApiResponse.ok(true, message)
} }
@PostMapping("/{characterId}/comments/{commentId}/reports") @PostMapping("/{characterId}/comments/{commentId}/reports")
@@ -100,9 +105,10 @@ class CharacterCommentController(
@RequestBody request: ReportCharacterCommentRequest, @RequestBody request: ReportCharacterCommentRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
service.reportComment(characterId, commentId, member, request.content) service.reportComment(characterId, commentId, member, request.content)
ApiResponse.ok(true, "신고가 접수되었습니다.") val message = messageSource.getMessage("chat.character.comment.reported", langContext.lang)
ApiResponse.ok(true, message)
} }
} }

View File

@@ -36,7 +36,7 @@ class CharacterCommentService(
entity: CharacterComment, entity: CharacterComment,
replyCountOverride: Int? = null replyCountOverride: Int? = null
): CharacterCommentResponse { ): CharacterCommentResponse {
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") val member = entity.member ?: throw SodaException(messageKey = "chat.character.comment.invalid")
return CharacterCommentResponse( return CharacterCommentResponse(
commentId = entity.id!!, commentId = entity.id!!,
memberId = member.id!!, memberId = member.id!!,
@@ -50,7 +50,7 @@ class CharacterCommentService(
} }
private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse { private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse {
val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") val member = entity.member ?: throw SodaException(messageKey = "chat.character.comment.invalid")
return CharacterReplyResponse( return CharacterReplyResponse(
replyId = entity.id!!, replyId = entity.id!!,
memberId = member.id!!, memberId = member.id!!,
@@ -64,9 +64,10 @@ class CharacterCommentService(
@Transactional @Transactional
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long { fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } val character = chatCharacterRepository.findById(characterId)
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive")
if (text.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val entity = CharacterComment(comment = text, languageCode = languageCode) val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character entity.chatCharacter = character
@@ -95,12 +96,14 @@ class CharacterCommentService(
text: String, text: String,
languageCode: String? = null languageCode: String? = null
): Long { ): Long {
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } val character = chatCharacterRepository.findById(characterId)
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive")
if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") val parent = commentRepository.findById(parentCommentId)
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.") .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") }
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") if (parent.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request")
if (!parent.isActive) throw SodaException(messageKey = "chat.character.comment.inactive")
if (text.isBlank()) throw SodaException(messageKey = "chat.character.comment.required")
val entity = CharacterComment(comment = text, languageCode = languageCode) val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character entity.chatCharacter = character
@@ -162,9 +165,9 @@ class CharacterCommentService(
limit: Int = 20 limit: Int = 20
): CharacterCommentRepliesResponse { ): CharacterCommentRepliesResponse {
val original = commentRepository.findById(commentId).orElseThrow { val original = commentRepository.findById(commentId).orElseThrow {
SodaException("댓글을 찾을 수 없습니다.") SodaException(messageKey = "chat.character.comment.not_found")
} }
if (!original.isActive) throw SodaException("비활성화된 댓글입니다.") if (!original.isActive) throw SodaException(messageKey = "chat.character.comment.inactive")
val pageable = PageRequest.of(0, limit) val pageable = PageRequest.of(0, limit)
val replies = if (cursor == null) { val replies = if (cursor == null) {
@@ -207,20 +210,22 @@ class CharacterCommentService(
@Transactional @Transactional
fun deleteComment(characterId: Long, commentId: Long, member: Member) { fun deleteComment(characterId: Long, commentId: Long, member: Member) {
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } val comment = commentRepository.findById(commentId)
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") }
if (comment.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request")
if (!comment.isActive) return if (!comment.isActive) return
val ownerId = comment.member?.id ?: throw SodaException("유효하지 않은 댓글입니다.") val ownerId = comment.member?.id ?: throw SodaException(messageKey = "chat.character.comment.invalid")
if (ownerId != member.id) throw SodaException("삭제 권한이 없습니다.") if (ownerId != member.id) throw SodaException(messageKey = "chat.character.comment.delete_forbidden")
comment.isActive = false comment.isActive = false
commentRepository.save(comment) commentRepository.save(comment)
} }
@Transactional @Transactional
fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) { fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) {
val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } val comment = commentRepository.findById(commentId)
if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") .orElseThrow { SodaException(messageKey = "chat.character.comment.not_found") }
if (content.isBlank()) throw SodaException("신고 내용을 입력해주세요.") if (comment.chatCharacter?.id != characterId) throw SodaException(messageKey = "common.error.invalid_request")
if (content.isBlank()) throw SodaException(messageKey = "chat.character.comment.report_content_required")
val report = CharacterCommentReport(content = content) val report = CharacterCommentReport(content = content)
report.comment = comment report.comment = comment

View File

@@ -155,12 +155,12 @@ class ChatCharacterController(
@PathVariable characterId: Long, @PathVariable characterId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
// 캐릭터 상세 정보 조회 // 캐릭터 상세 정보 조회
val character = service.getCharacterDetail(characterId) val character = service.getCharacterDetail(characterId)
?: throw SodaException("캐릭터를 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.character.not_found")
// 태그 가공: # prefix 규칙 적용 후 공백으로 연결 // 태그 가공: # prefix 규칙 적용 후 공백으로 연결
val tags = character.tagMappings val tags = character.tagMappings

View File

@@ -36,8 +36,8 @@ class CharacterImageController(
@RequestParam(required = false, defaultValue = "20") size: Int, @RequestParam(required = false, defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val pageSize = if (size <= 0) 20 else minOf(size, 20) val pageSize = if (size <= 0) 20 else minOf(size, 20)
@@ -124,8 +124,8 @@ class CharacterImageController(
@RequestParam(required = false, defaultValue = "20") size: Int, @RequestParam(required = false, defaultValue = "20") size: Int,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val pageSize = if (size <= 0) 20 else minOf(size, 20) val pageSize = if (size <= 0) 20 else minOf(size, 20)
val expiration = 5L * 60L * 1000L // 5분 val expiration = 5L * 60L * 1000L // 5분
@@ -198,18 +198,18 @@ class CharacterImageController(
@RequestBody req: CharacterImagePurchaseRequest, @RequestBody req: CharacterImagePurchaseRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val image = imageService.getById(req.imageId) val image = imageService.getById(req.imageId)
if (!image.isActive) throw SodaException("비활성화된 이미지입니다.") if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive")
val isOwned = (image.imagePriceCan == 0L) || val isOwned = (image.imagePriceCan == 0L) ||
imageService.isOwnedImageByMember(image.id!!, member.id!!) imageService.isOwnedImageByMember(image.id!!, member.id!!)
if (!isOwned) { if (!isOwned) {
val needCan = image.imagePriceCan.toInt() val needCan = image.imagePriceCan.toInt()
if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.") if (needCan <= 0) throw SodaException(messageKey = "chat.purchase.invalid_price")
canPaymentService.spendCanForCharacterImage( canPaymentService.spendCanForCharacterImage(
memberId = member.id!!, memberId = member.id!!,

View File

@@ -64,11 +64,11 @@ class CharacterImageService(
} }
fun getById(id: Long): CharacterImage = fun getById(id: Long): CharacterImage =
imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } imageRepository.findById(id).orElseThrow { SodaException(messageKey = "chat.character.image.not_found") }
fun getCharacterImagePath(characterId: Long): String? { fun getCharacterImagePath(characterId: Long): String? {
val character = characterRepository.findById(characterId) val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
return character.imagePath return character.imagePath
} }
@@ -94,11 +94,13 @@ class CharacterImageService(
triggers: List<String> triggers: List<String>
): CharacterImage { ): CharacterImage {
val character = characterRepository.findById(characterId) val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.") if (imagePriceCan < 0 || messagePriceCan < 0) {
throw SodaException(messageKey = "chat.character.image.min_price")
}
if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") if (!character.isActive) throw SodaException(messageKey = "chat.character.inactive_image_register")
val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1
val entity = CharacterImage( val entity = CharacterImage(
@@ -122,7 +124,7 @@ class CharacterImageService(
@Transactional @Transactional
fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage { fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage {
val image = getById(imageId) val image = getById(imageId)
if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId") if (!image.isActive) throw SodaException(messageKey = "chat.character.image.inactive_update")
applyTriggers(image, triggers) applyTriggers(image, triggers)
return image return image
} }
@@ -159,8 +161,10 @@ class CharacterImageService(
val updated = mutableListOf<CharacterImage>() val updated = mutableListOf<CharacterImage>()
ids.forEachIndexed { idx, id -> ids.forEachIndexed { idx, id ->
val img = getById(id) val img = getById(id)
if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id") if (img.chatCharacter.id != characterId) {
if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id") throw SodaException(messageKey = "chat.character.image.other_character_included")
}
if (!img.isActive) throw SodaException(messageKey = "chat.character.image.inactive_order_change")
img.sortOrder = idx + 1 img.sortOrder = idx + 1
updated.add(img) updated.add(img)
} }

View File

@@ -26,7 +26,7 @@ class ChatCharacterBannerService(
*/ */
fun getBannerById(bannerId: Long): ChatCharacterBanner { fun getBannerById(bannerId: Long): ChatCharacterBanner {
return bannerRepository.findById(bannerId) return bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") }
} }
/** /**
@@ -39,10 +39,10 @@ class ChatCharacterBannerService(
@Transactional @Transactional
fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner {
val character = characterRepository.findById(characterId) val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
if (!character.isActive) { if (!character.isActive) {
throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId") throw SodaException(messageKey = "chat.character.inactive_banner_register")
} }
// 정렬 순서가 지정되지 않은 경우 가장 마지막 순서로 설정 // 정렬 순서가 지정되지 않은 경우 가장 마지막 순서로 설정
@@ -68,10 +68,10 @@ class ChatCharacterBannerService(
@Transactional @Transactional
fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner { fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner {
val banner = bannerRepository.findById(bannerId) val banner = bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") }
if (!banner.isActive) { if (!banner.isActive) {
throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") throw SodaException(messageKey = "chat.character.banner.inactive_update")
} }
// 이미지 경로 변경 // 이미지 경로 변경
@@ -82,10 +82,10 @@ class ChatCharacterBannerService(
// 캐릭터 변경 // 캐릭터 변경
if (characterId != null) { if (characterId != null) {
val character = characterRepository.findById(characterId) val character = characterRepository.findById(characterId)
.orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } .orElseThrow { SodaException(messageKey = "chat.character.not_found") }
if (!character.isActive) { if (!character.isActive) {
throw SodaException("비활성화된 캐릭터로는 변경할 수 없습니다: $characterId") throw SodaException(messageKey = "chat.character.inactive_banner_change")
} }
banner.chatCharacter = character banner.chatCharacter = character
@@ -100,7 +100,7 @@ class ChatCharacterBannerService(
@Transactional @Transactional
fun deleteBanner(bannerId: Long) { fun deleteBanner(bannerId: Long) {
val banner = bannerRepository.findById(bannerId) val banner = bannerRepository.findById(bannerId)
.orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") }
banner.isActive = false banner.isActive = false
bannerRepository.save(banner) bannerRepository.save(banner)
@@ -119,10 +119,10 @@ class ChatCharacterBannerService(
for (index in ids.indices) { for (index in ids.indices) {
val banner = bannerRepository.findById(ids[index]) val banner = bannerRepository.findById(ids[index])
.orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } .orElseThrow { SodaException(messageKey = "chat.character.banner.not_found") }
if (!banner.isActive) { if (!banner.isActive) {
throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") throw SodaException(messageKey = "chat.character.banner.inactive_update")
} }
banner.sortOrder = index + 1 banner.sortOrder = index + 1

View File

@@ -702,7 +702,7 @@ class ChatCharacterService(
): ChatCharacter { ): ChatCharacter {
// 캐릭터 조회 // 캐릭터 조회
val chatCharacter = findById(request.id) val chatCharacter = findById(request.id)
?: throw kr.co.vividnext.sodalive.common.SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") ?: throw kr.co.vividnext.sodalive.common.SodaException(messageKey = "chat.character.not_found")
// isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다. // isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다.
if (request.isActive != null && !request.isActive) { if (request.isActive != null && !request.isActive) {

View File

@@ -126,8 +126,8 @@ class OriginalWorkController(
@PathVariable id: Long, @PathVariable id: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val ow = queryService.getOriginalWork(id) val ow = queryService.getOriginalWork(id)
val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content val chars = queryService.getActiveCharactersPage(id, page = 0, size = 20).content

View File

@@ -44,7 +44,7 @@ class OriginalWorkQueryService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork { fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id) return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } .orElseThrow { SodaException(messageKey = "chat.original.not_found") }
} }
/** /**
@@ -54,7 +54,7 @@ class OriginalWorkQueryService(
fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> { fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인 // 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } .orElseThrow { SodaException(messageKey = "chat.original.not_found") }
val safePage = if (page < 0) 0 else page val safePage = if (page < 0) 0 else page
val safeSize = when { val safeSize = when {

View File

@@ -32,8 +32,8 @@ class ChatQuotaController(
fun getMyQuota( fun getMyQuota(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
): ApiResponse<ChatQuotaStatusResponse> = run { ): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val s = chatQuotaService.getStatus(member.id!!) val s = chatQuotaService.getStatus(member.id!!)
ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis))
@@ -44,9 +44,9 @@ class ChatQuotaController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: ChatQuotaPurchaseRequest @RequestBody request: ChatQuotaPurchaseRequest
): ApiResponse<ChatQuotaStatusResponse> = run { ): ApiResponse<ChatQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.container.isBlank()) throw SodaException("container를 확인해주세요.") if (request.container.isBlank()) throw SodaException(messageKey = "chat.quota.container_required")
// 30캔 차감 처리 (결제 기록 남김) // 30캔 차감 처리 (결제 기록 남김)
canPaymentService.spendCan( canPaymentService.spendCan(

View File

@@ -52,27 +52,27 @@ class ChatRoomQuotaController(
@PathVariable chatRoomId: Long, @PathVariable chatRoomId: Long,
@RequestBody req: PurchaseRoomQuotaRequest @RequestBody req: PurchaseRoomQuotaRequest
): ApiResponse<PurchaseRoomQuotaResponse> = run { ): ApiResponse<PurchaseRoomQuotaResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (req.container.isBlank()) throw SodaException("잘못된 접근입니다") if (req.container.isBlank()) throw SodaException(messageKey = "chat.room.quota.invalid_access")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
// 내 참여 여부 확인 // 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.quota.invalid_access")
// 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조) // 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조)
val characterParticipant = participantRepository val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
val character = characterParticipant.character val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
val characterId = character.id val characterId = character.id
?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.") ?: throw SodaException(messageKey = "chat.room.quota.character_required")
// 서비스에서 결제 포함하여 처리 // 서비스에서 결제 포함하여 처리
val status = chatRoomQuotaService.purchase( val status = chatRoomQuotaService.purchase(
@@ -98,20 +98,20 @@ class ChatRoomQuotaController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long @PathVariable chatRoomId: Long
): ApiResponse<RoomQuotaStatusResponse> = run { ): ApiResponse<RoomQuotaStatusResponse> = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
// 내 참여 여부 확인 // 내 참여 여부 확인
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.quota.invalid_access")
// 캐릭터 확인 // 캐릭터 확인
val characterParticipant = participantRepository val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
val character = characterParticipant.character val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.quota.not_ai_room")
// 글로벌 Lazy refill // 글로벌 Lazy refill
val globalStatus = chatQuotaService.getStatus(member.id!!) val globalStatus = chatQuotaService.getStatus(member.id!!)

View File

@@ -75,7 +75,7 @@ class ChatRoomQuotaService(
val now = Instant.now() val now = Instant.now()
val nowMillis = now.toEpochMilli() val nowMillis = now.toEpochMilli()
val quota = repo.findForUpdate(memberId, chatRoomId) val quota = repo.findForUpdate(memberId, chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
// 충전 시간이 지났다면 무료 10으로 리셋하고 next=null // 충전 시간이 지났다면 무료 10으로 리셋하고 next=null
if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) {
@@ -98,7 +98,7 @@ class ChatRoomQuotaService(
val globalFree = globalFreeProvider() val globalFree = globalFreeProvider()
if (globalFree <= 0) { if (globalFree <= 0) {
// 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가 // 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가
throw SodaException("오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.") throw SodaException(messageKey = "chat.room.quota.global_free_exhausted")
} }
if (quota.remainingFree <= 0) { if (quota.remainingFree <= 0) {
// 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가 // 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가
@@ -107,7 +107,7 @@ class ChatRoomQuotaService(
quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli() quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli()
} }
throw SodaException("무료 채팅이 모두 소진되었습니다.") throw SodaException(messageKey = "chat.room.quota.room_free_exhausted")
} }
// 둘 다 가능 → 차감 // 둘 다 가능 → 차감

View File

@@ -42,8 +42,8 @@ class ChatRoomController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@RequestBody request: CreateChatRoomRequest @RequestBody request: CreateChatRoomRequest
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.createOrGetChatRoom(member, request.characterId) val response = chatRoomService.createOrGetChatRoom(member, request.characterId)
ApiResponse.ok(response) ApiResponse.ok(response)
@@ -77,8 +77,8 @@ class ChatRoomController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long @PathVariable chatRoomId: Long
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId)
ApiResponse.ok(isActive) ApiResponse.ok(isActive)
@@ -95,8 +95,8 @@ class ChatRoomController(
@PathVariable chatRoomId: Long, @PathVariable chatRoomId: Long,
@RequestParam(required = false) characterImageId: Long? @RequestParam(required = false) characterImageId: Long?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId) val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId)
ApiResponse.ok(response) ApiResponse.ok(response)
@@ -114,8 +114,8 @@ class ChatRoomController(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long @PathVariable chatRoomId: Long
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
chatRoomService.leaveChatRoom(member, chatRoomId) chatRoomService.leaveChatRoom(member, chatRoomId)
ApiResponse.ok(true) ApiResponse.ok(true)
@@ -134,8 +134,8 @@ class ChatRoomController(
@RequestParam(defaultValue = "20") limit: Int, @RequestParam(defaultValue = "20") limit: Int,
@RequestParam(required = false) cursor: Long? @RequestParam(required = false) cursor: Long?
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit) val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit)
ApiResponse.ok(response) ApiResponse.ok(response)
@@ -153,8 +153,8 @@ class ChatRoomController(
@PathVariable chatRoomId: Long, @PathVariable chatRoomId: Long,
@RequestBody request: SendChatMessageRequest @RequestBody request: SendChatMessageRequest
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
if (request.message.isBlank()) { if (request.message.isBlank()) {
ApiResponse.error() ApiResponse.error()
@@ -176,8 +176,8 @@ class ChatRoomController(
@PathVariable messageId: Long, @PathVariable messageId: Long,
@RequestBody request: ChatMessagePurchaseRequest @RequestBody request: ChatMessagePurchaseRequest
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container)
ApiResponse.ok(result) ApiResponse.ok(result)
@@ -195,8 +195,8 @@ class ChatRoomController(
@PathVariable chatRoomId: Long, @PathVariable chatRoomId: Long,
@RequestBody request: ChatRoomResetRequest @RequestBody request: ChatRoomResetRequest
) = run { ) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (member.auth == null) throw SodaException(messageKey = "common.error.adult_verification_required")
val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container) val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container)
ApiResponse.ok(response) ApiResponse.ok(response)

View File

@@ -29,6 +29,7 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -54,6 +55,7 @@ class ChatRoomService(
private val characterService: ChatCharacterService, private val characterService: ChatCharacterService,
private val characterImageService: CharacterImageService, private val characterImageService: CharacterImageService,
private val langContext: LangContext, private val langContext: LangContext,
private val messageSource: SodaMessageSource,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService,
private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront,
@@ -77,19 +79,19 @@ class ChatRoomService(
@Transactional @Transactional
fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto { fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto {
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
// 참여 여부 검증 // 참여 여부 검증
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.invalid_access")
val message = messageRepository.findById(messageId).orElseThrow { val message = messageRepository.findById(messageId).orElseThrow {
SodaException("메시지를 찾을 수 없습니다.") SodaException(messageKey = "chat.message.not_found")
} }
if (!message.isActive) throw SodaException("비활성화된 메시지입니다.") if (!message.isActive) throw SodaException(messageKey = "chat.message.inactive")
if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다") if (message.chatRoom.id != room.id) throw SodaException(messageKey = "chat.room.invalid_access")
val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.") val price = message.price ?: throw SodaException(messageKey = "chat.message.not_purchasable")
if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.") if (price <= 0) throw SodaException(messageKey = "chat.purchase.invalid_price")
// 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환 // 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환
if (message.messageType == ChatMessageType.IMAGE) { if (message.messageType == ChatMessageType.IMAGE) {
@@ -124,7 +126,7 @@ class ChatRoomService(
fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse { fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse {
// 1. 캐릭터 조회 // 1. 캐릭터 조회
val character = characterService.findById(characterId) val character = characterService.findById(characterId)
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") ?: throw SodaException(messageKey = "chat.room.character_not_found")
// 2. 이미 참여 중인 채팅방이 있는지 확인 // 2. 이미 참여 중인 채팅방이 있는지 확인
val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character) val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character)
@@ -225,21 +227,21 @@ class ChatRoomService(
// success가 false이면 throw // success가 false이면 throw
if (!apiResponse.success) { if (!apiResponse.success) {
throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") throw SodaException(messageKey = "chat.room.create_failed_retry")
} }
// success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인
val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") val data = apiResponse.data ?: throw SodaException(messageKey = "chat.room.create_failed_retry")
if (data.userId != userId && data.character.id != characterUUID && data.status != "active") { if (data.userId != userId && data.character.id != characterUUID && data.status != "active") {
throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") throw SodaException(messageKey = "chat.room.create_failed_retry")
} }
// 세션 ID 반환 // 세션 ID 반환
return data.sessionId return data.sessionId
} catch (e: Exception) { } catch (e: Exception) {
log.error(e.message) log.error(e.message)
throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") throw SodaException(messageKey = "chat.room.create_failed_retry")
} }
} }
@@ -264,7 +266,7 @@ class ChatRoomService(
} }
} else { } else {
if (latest?.message.isNullOrBlank() && latest?.characterImage != null) { if (latest?.message.isNullOrBlank() && latest?.characterImage != null) {
"[이미지]" messageSource.getMessage("chat.room.last_message_image", langContext.lang).orEmpty()
} else { } else {
"" ""
} }
@@ -304,11 +306,19 @@ class ChatRoomService(
val now = LocalDateTime.now() val now = LocalDateTime.now()
val duration = Duration.between(time, now) val duration = Duration.between(time, now)
val seconds = duration.seconds val seconds = duration.seconds
if (seconds <= 60) return "방금" if (seconds <= 60) {
return messageSource.getMessage("chat.room.time.just_now", langContext.lang).orEmpty()
}
val minutes = duration.toMinutes() val minutes = duration.toMinutes()
if (minutes < 60) return "${minutes}분 전" if (minutes < 60) {
val template = messageSource.getMessage("chat.room.time.minutes_ago", langContext.lang).orEmpty()
return String.format(template, minutes)
}
val hours = duration.toHours() val hours = duration.toHours()
if (hours < 24) return "${hours}시간 전" if (hours < 24) {
val template = messageSource.getMessage("chat.room.time.hours_ago", langContext.lang).orEmpty()
return String.format(template, hours)
}
// 그 외: 날짜 (yyyy-MM-dd) // 그 외: 날짜 (yyyy-MM-dd)
return time.toLocalDate().toString() return time.toLocalDate().toString()
} }
@@ -510,23 +520,23 @@ class ChatRoomService(
// success가 false이면 throw // success가 false이면 throw
if (!apiResponse.success) { if (!apiResponse.success) {
throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") throw SodaException(messageKey = "chat.error.retry")
} }
val status = apiResponse.data?.status val status = apiResponse.data?.status
return status == "active" return status == "active"
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") throw SodaException(messageKey = "chat.error.retry")
} }
} }
@Transactional @Transactional
fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) {
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.invalid_access")
// 1) 나가기 처리 // 1) 나가기 처리
participant.isActive = false participant.isActive = false
@@ -589,10 +599,9 @@ class ChatRoomService(
} }
} }
// 최종 실패 처리 // 최종 실패 처리
val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요."
if (throwOnFailure) { if (throwOnFailure) {
log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts) log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts)
throw SodaException(message) throw SodaException(messageKey = "chat.room.session_end_failed")
} else { } else {
log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts)
} }
@@ -601,9 +610,9 @@ class ChatRoomService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse {
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.invalid_access")
val pageable = PageRequest.of(0, limit) val pageable = PageRequest.of(0, limit)
val fetched = if (cursor != null) { val fetched = if (cursor != null) {
@@ -636,18 +645,18 @@ class ChatRoomService(
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인 // 1) 방 존재 확인
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
// 2) 참여 여부 확인 (USER) // 2) 참여 여부 확인 (USER)
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.invalid_access")
// 3) 캐릭터 참여자 조회 // 3) 캐릭터 참여자 조회
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
room, room,
ParticipantType.CHARACTER ParticipantType.CHARACTER
) ?: throw SodaException("잘못된 접근입니다") ) ?: throw SodaException(messageKey = "chat.room.invalid_access")
val character = characterParticipant.character val character = characterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") ?: throw SodaException(messageKey = "chat.error.retry")
// 4) 외부 API 호출 준비 // 4) 외부 API 호출 준비
val userId = generateUserId(member.id!!) val userId = generateUserId(member.id!!)
@@ -833,7 +842,7 @@ class ChatRoomService(
} }
} }
log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts) log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts)
throw SodaException("메시지 전송을 실패했습니다.") throw SodaException(messageKey = "chat.message.send_failed")
} }
private fun callExternalApiForChatSend( private fun callExternalApiForChatSend(
@@ -875,12 +884,12 @@ class ChatRoomService(
) )
if (!apiResponse.success) { if (!apiResponse.success) {
throw SodaException("메시지 전송을 실패했습니다.") throw SodaException(messageKey = "chat.message.send_failed")
} }
val data = apiResponse.data ?: throw SodaException("메시지 전송을 실패했습니다.") val data = apiResponse.data ?: throw SodaException(messageKey = "chat.message.send_failed")
val characterContent = data.characterResponse.content val characterContent = data.characterResponse.content
if (characterContent.isBlank()) { if (characterContent.isBlank()) {
throw SodaException("메시지 전송을 실패했습니다.") throw SodaException(messageKey = "chat.message.send_failed")
} }
return characterContent return characterContent
} }
@@ -903,16 +912,16 @@ class ChatRoomService(
fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse { fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse {
// 0) 방 존재 및 내 참여 여부 확인 // 0) 방 존재 및 내 참여 여부 확인
val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId)
?: throw SodaException("채팅방을 찾을 수 없습니다.") ?: throw SodaException(messageKey = "chat.error.room_not_found")
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다") ?: throw SodaException(messageKey = "chat.room.invalid_access")
// 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인) // 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인)
val characterParticipant = participantRepository val characterParticipant = participantRepository
.findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER)
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.not_ai_room")
val character = characterParticipant.character val character = characterParticipant.character
?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") ?: throw SodaException(messageKey = "chat.room.not_ai_room")
// 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용) // 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용)
canPaymentService.spendCan( canPaymentService.spendCan(

View File

@@ -20,6 +20,11 @@ class SodaMessageSource {
Lang.EN to "Please check your login information.", Lang.EN to "Please check your login information.",
Lang.JA to "ログイン情報を確認してください。" Lang.JA to "ログイン情報を確認してください。"
), ),
"common.error.adult_verification_required" to mapOf(
Lang.KO to "본인인증을 하셔야 합니다.",
Lang.EN to "Identity verification is required.",
Lang.JA to "本人認証が必要です。"
),
"common.error.max_upload_size" to mapOf( "common.error.max_upload_size" to mapOf(
Lang.KO to "파일용량은 최대 1024MB까지 저장할 수 있습니다.", Lang.KO to "파일용량은 최대 1024MB까지 저장할 수 있습니다.",
Lang.EN to "The file size can be saved up to 1024MB.", Lang.EN to "The file size can be saved up to 1024MB.",
@@ -1662,6 +1667,245 @@ class SodaMessageSource {
) )
) )
private val chatCharacterCommentMessages = mapOf(
"chat.character.comment.required" to mapOf(
Lang.KO to "댓글 내용을 입력해주세요.",
Lang.EN to "Please enter a comment.",
Lang.JA to "コメント内容を入力してください。"
),
"chat.character.comment.deleted" to mapOf(
Lang.KO to "댓글이 삭제되었습니다.",
Lang.EN to "The comment has been deleted.",
Lang.JA to "コメントが削除されました。"
),
"chat.character.comment.reported" to mapOf(
Lang.KO to "신고가 접수되었습니다.",
Lang.EN to "Your report has been received.",
Lang.JA to "通報が受け付けられました。"
),
"chat.character.comment.invalid" to mapOf(
Lang.KO to "유효하지 않은 댓글입니다.",
Lang.EN to "Invalid comment.",
Lang.JA to "無効なコメントです。"
),
"chat.character.comment.not_found" to mapOf(
Lang.KO to "댓글을 찾을 수 없습니다.",
Lang.EN to "Comment not found.",
Lang.JA to "コメントが見つかりません。"
),
"chat.character.comment.inactive" to mapOf(
Lang.KO to "비활성화된 댓글입니다.",
Lang.EN to "This comment is inactive.",
Lang.JA to "無効化されたコメントです。"
),
"chat.character.comment.delete_forbidden" to mapOf(
Lang.KO to "삭제 권한이 없습니다.",
Lang.EN to "You do not have permission to delete.",
Lang.JA to "削除権限がありません。"
),
"chat.character.comment.report_content_required" to mapOf(
Lang.KO to "신고 내용을 입력해주세요.",
Lang.EN to "Please enter a report message.",
Lang.JA to "通報内容を入力してください。"
)
)
private val chatCharacterMessages = mapOf(
"chat.character.not_found" to mapOf(
Lang.KO to "캐릭터를 찾을 수 없습니다.",
Lang.EN to "Character not found.",
Lang.JA to "キャラクターが見つかりません。"
),
"chat.character.inactive" to mapOf(
Lang.KO to "비활성화된 캐릭터입니다.",
Lang.EN to "This character is inactive.",
Lang.JA to "無効化されたキャラクターです。"
),
"chat.character.inactive_image_register" to mapOf(
Lang.KO to "비활성화된 캐릭터에는 이미지를 등록할 수 없습니다.",
Lang.EN to "Images cannot be registered for an inactive character.",
Lang.JA to "無効化されたキャラクターには画像を登録できません。"
),
"chat.character.inactive_banner_register" to mapOf(
Lang.KO to "비활성화된 캐릭터에는 배너를 등록할 수 없습니다.",
Lang.EN to "Banners cannot be registered for an inactive character.",
Lang.JA to "無効化されたキャラクターにはバナーを登録できません。"
),
"chat.character.inactive_banner_change" to mapOf(
Lang.KO to "비활성화된 캐릭터로는 변경할 수 없습니다.",
Lang.EN to "You cannot change to an inactive character.",
Lang.JA to "無効化されたキャラクターには変更できません。"
)
)
private val chatCharacterImageMessages = mapOf(
"chat.character.image.not_found" to mapOf(
Lang.KO to "캐릭터 이미지를 찾을 수 없습니다.",
Lang.EN to "Character image not found.",
Lang.JA to "キャラクター画像が見つかりません。"
),
"chat.character.image.inactive" to mapOf(
Lang.KO to "비활성화된 이미지입니다.",
Lang.EN to "This image is inactive.",
Lang.JA to "無効化された画像です。"
),
"chat.character.image.min_price" to mapOf(
Lang.KO to "가격은 0 can 이상이어야 합니다.",
Lang.EN to "Price must be at least 0 can.",
Lang.JA to "価格は0can以上である必要があります。"
),
"chat.character.image.inactive_update" to mapOf(
Lang.KO to "비활성화된 이미지는 수정할 수 없습니다.",
Lang.EN to "Inactive images cannot be updated.",
Lang.JA to "無効化された画像は修正できません。"
),
"chat.character.image.other_character_included" to mapOf(
Lang.KO to "다른 캐릭터의 이미지가 포함되어 있습니다.",
Lang.EN to "Images from another character are included.",
Lang.JA to "別のキャラクターの画像が含まれています。"
),
"chat.character.image.inactive_order_change" to mapOf(
Lang.KO to "비활성화된 이미지는 순서를 변경할 수 없습니다.",
Lang.EN to "Inactive images cannot change order.",
Lang.JA to "無効化された画像の順序は変更できません。"
)
)
private val chatCharacterBannerMessages = mapOf(
"chat.character.banner.not_found" to mapOf(
Lang.KO to "배너를 찾을 수 없습니다.",
Lang.EN to "Banner not found.",
Lang.JA to "バナーが見つかりません。"
),
"chat.character.banner.inactive_update" to mapOf(
Lang.KO to "비활성화된 배너는 수정할 수 없습니다.",
Lang.EN to "Inactive banners cannot be updated.",
Lang.JA to "無効化されたバナーは修正できません。"
)
)
private val chatOriginalWorkMessages = mapOf(
"chat.original.not_found" to mapOf(
Lang.KO to "해당 원작을 찾을 수 없습니다.",
Lang.EN to "Original work not found.",
Lang.JA to "該当する原作が見つかりません。"
)
)
private val chatQuotaMessages = mapOf(
"chat.quota.container_required" to mapOf(
Lang.KO to "container를 확인해주세요.",
Lang.EN to "Please check the container.",
Lang.JA to "containerを確認してください。"
)
)
private val chatRoomQuotaMessages = mapOf(
"chat.room.quota.invalid_access" to mapOf(
Lang.KO to "잘못된 접근입니다",
Lang.EN to "Invalid access.",
Lang.JA to "不正なアクセスです。"
),
"chat.room.quota.not_ai_room" to mapOf(
Lang.KO to "AI 캐릭터 채팅방이 아닙니다.",
Lang.EN to "This is not an AI character chat room.",
Lang.JA to "AIキャラクターのチャットルームではありません。"
),
"chat.room.quota.character_required" to mapOf(
Lang.KO to "잘못된 요청입니다. 캐릭터 정보를 확인해주세요.",
Lang.EN to "Invalid request. Please check the character information.",
Lang.JA to "不正なリクエストです。キャラクター情報を確認してください。"
),
"chat.room.quota.global_free_exhausted" to mapOf(
Lang.KO to "오늘의 무료 채팅이 모두 소진되었습니다. 내일 다시 이용해 주세요.",
Lang.EN to "Today's free chats have been used up. Please try again tomorrow.",
Lang.JA to "本日の無料チャットはすべて使い切りました。明日またご利用ください。"
),
"chat.room.quota.room_free_exhausted" to mapOf(
Lang.KO to "무료 채팅이 모두 소진되었습니다.",
Lang.EN to "Free chats have been used up.",
Lang.JA to "無料チャットはすべて使い切りました。"
)
)
private val chatRoomMessages = mapOf(
"chat.room.invalid_access" to mapOf(
Lang.KO to "잘못된 접근입니다",
Lang.EN to "Invalid access.",
Lang.JA to "不正なアクセスです。"
),
"chat.room.not_ai_room" to mapOf(
Lang.KO to "AI 캐릭터 채팅방이 아닙니다.",
Lang.EN to "This is not an AI character chat room.",
Lang.JA to "AIキャラクターのチャットルームではありません。"
),
"chat.message.not_found" to mapOf(
Lang.KO to "메시지를 찾을 수 없습니다.",
Lang.EN to "Message not found.",
Lang.JA to "メッセージが見つかりません。"
),
"chat.message.inactive" to mapOf(
Lang.KO to "비활성화된 메시지입니다.",
Lang.EN to "This message is inactive.",
Lang.JA to "無効化されたメッセージです。"
),
"chat.message.not_purchasable" to mapOf(
Lang.KO to "구매할 수 없는 메시지입니다.",
Lang.EN to "This message cannot be purchased.",
Lang.JA to "購入できないメッセージです。"
),
"chat.purchase.invalid_price" to mapOf(
Lang.KO to "구매 가격이 잘못되었습니다.",
Lang.EN to "Invalid purchase price.",
Lang.JA to "購入価格が正しくありません。"
),
"chat.room.character_not_found" to mapOf(
Lang.KO to "해당 ID의 캐릭터를 찾을 수 없습니다.",
Lang.EN to "Character not found for the given ID.",
Lang.JA to "該当IDのキャラクターが見つかりません。"
),
"chat.room.create_failed_retry" to mapOf(
Lang.KO to "채팅방 생성에 실패했습니다. 다시 시도해 주세요.",
Lang.EN to "Failed to create the chat room. Please try again.",
Lang.JA to "チャットルームの作成に失敗しました。もう一度お試しください。"
),
"chat.error.retry" to mapOf(
Lang.KO to "오류가 발생했습니다. 다시 시도해 주세요.",
Lang.EN to "An error occurred. Please try again.",
Lang.JA to "エラーが発生しました。もう一度お試しください。"
),
"chat.room.session_end_failed" to mapOf(
Lang.KO to "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요.",
Lang.EN to "Failed to end the chat room session. Please try again.",
Lang.JA to "チャットルームのセッション終了に失敗しました。もう一度お試しください。"
),
"chat.message.send_failed" to mapOf(
Lang.KO to "메시지 전송을 실패했습니다.",
Lang.EN to "Failed to send the message.",
Lang.JA to "メッセージの送信に失敗しました。"
),
"chat.room.last_message_image" to mapOf(
Lang.KO to "[이미지]",
Lang.EN to "[Image]",
Lang.JA to "[画像]"
),
"chat.room.time.just_now" to mapOf(
Lang.KO to "방금",
Lang.EN to "Just now",
Lang.JA to "たった今"
),
"chat.room.time.minutes_ago" to mapOf(
Lang.KO to "%d분 전",
Lang.EN to "%d minutes ago",
Lang.JA to "%d分前"
),
"chat.room.time.hours_ago" to mapOf(
Lang.KO to "%d시간 전",
Lang.EN to "%d hours ago",
Lang.JA to "%d時間前"
)
)
private val creatorCommunityMessages = mapOf( private val creatorCommunityMessages = mapOf(
"creator.community.paid_post_image_required" to mapOf( "creator.community.paid_post_image_required" to mapOf(
Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.", Lang.KO to "유료 게시글 등록을 위해서는 이미지가 필요합니다.",
@@ -1772,6 +2016,14 @@ class SodaMessageSource {
creatorAdminContentMessages, creatorAdminContentMessages,
creatorAdminSeriesRequestMessages, creatorAdminSeriesRequestMessages,
creatorAdminSeriesMessages, creatorAdminSeriesMessages,
chatCharacterCommentMessages,
chatCharacterMessages,
chatCharacterImageMessages,
chatCharacterBannerMessages,
chatOriginalWorkMessages,
chatQuotaMessages,
chatRoomQuotaMessages,
chatRoomMessages,
creatorCommunityMessages creatorCommunityMessages
) )
for (messages in messageGroups) { for (messages in messageGroups) {