채팅 메시지 다국어 분리

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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