관리자 채팅 메시지 다국어 처리

This commit is contained in:
2025-12-22 22:30:05 +09:00
parent 14d0ae9851
commit 280b21c3cb
10 changed files with 231 additions and 50 deletions

View File

@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
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.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
@@ -35,6 +37,8 @@ class AdminChatBannerController(
private val bannerService: ChatCharacterBannerService,
private val adminCharacterService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val langContext: LangContext,
private val messageSource: SodaMessageSource,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@@ -158,8 +162,8 @@ class AdminChatBannerController(
filePath = "characters/banners/$bannerId/$fileName",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
} catch (_: Exception) {
throw SodaException(messageKey = "admin.chat.banner.image_save_failed")
}
}
@@ -208,7 +212,8 @@ class AdminChatBannerController(
fun deleteBanner(@PathVariable bannerId: Long) = run {
bannerService.deleteBanner(bannerId)
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
val message = messageSource.getMessage("admin.chat.banner.delete_success", langContext.lang)
ApiResponse.ok(message)
}
/**
@@ -224,6 +229,7 @@ class AdminChatBannerController(
) = run {
bannerService.updateBannerOrders(request.ids)
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
val message = messageSource.getMessage("admin.chat.banner.reorder_success", langContext.lang)
ApiResponse.ok(null, message)
}
}

View File

@@ -29,13 +29,13 @@ class AdminChatCalculateService(
val todayKst = LocalDate.now(kstZone)
if (endDate.isAfter(todayKst)) {
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
throw SodaException(messageKey = "admin.chat.calculate.end_date_max_today")
}
if (startDate.isAfter(endDate)) {
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
throw SodaException(messageKey = "admin.chat.calculate.start_date_after_end")
}
if (endDate.isAfter(startDate.plusMonths(6))) {
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
throw SodaException(messageKey = "admin.chat.calculate.max_period_6_months")
}
val startUtc = startDateStr.convertLocalDateTime()

View File

@@ -124,7 +124,7 @@ class AdminChatCharacterController(
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
val existingCharacter = service.findByName(request.name)
if (existingCharacter != null) {
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
throw SodaException(messageKey = "admin.chat.character.duplicate_name")
}
// 1. 외부 API 호출
@@ -233,14 +233,18 @@ class AdminChatCharacterController(
// success가 false이면 throw
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
val apiMessage = apiResponse.message
if (apiMessage.isNullOrBlank()) {
throw SodaException(messageKey = "admin.chat.character.register_failed_retry")
}
throw SodaException(apiMessage)
}
// success가 true이면 data.id 반환
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
return apiResponse.data?.id ?: throw SodaException(messageKey = "admin.chat.character.register_failed_no_id")
} catch (e: Exception) {
e.printStackTrace()
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
throw SodaException(messageKey = "admin.chat.character.register_failed_retry")
}
}
@@ -257,7 +261,7 @@ class AdminChatCharacterController(
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
throw SodaException(messageKey = "admin.chat.character.image_save_failed")
}
}
@@ -297,19 +301,19 @@ class AdminChatCharacterController(
request.originalWorkId != null
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
throw SodaException("변경된 데이터가 없습니다.")
throw SodaException(messageKey = "admin.chat.character.no_changes")
}
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
if (hasChangedData) {
val chatCharacter = service.findById(request.id)
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
?: throw SodaException(messageKey = "admin.chat.character.not_found")
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
if (request.name != null && request.name != chatCharacter.name) {
val existingCharacter = service.findByName(request.name)
if (existingCharacter != null) {
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
throw SodaException(messageKey = "admin.chat.character.duplicate_name")
}
}
@@ -438,11 +442,15 @@ class AdminChatCharacterController(
// success가 false이면 throw
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
val apiMessage = apiResponse.message
if (apiMessage.isNullOrBlank()) {
throw SodaException(messageKey = "admin.chat.character.update_failed_retry")
}
throw SodaException(apiMessage)
}
} catch (e: Exception) {
e.printStackTrace()
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
throw SodaException(messageKey = "admin.chat.character.update_failed_retry")
}
}
}

View File

@@ -63,7 +63,7 @@ class CharacterCurationAdminController(
@RequestBody request: CharacterCurationAddCharacterRequest
): ApiResponse<Boolean> {
val ids = request.characterIds.filter { it > 0 }.distinct()
if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
if (ids.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty")
service.addCharacters(curationId, ids)
return ApiResponse.ok(true)
}

View File

@@ -32,7 +32,7 @@ class CharacterCurationAdminService(
@Transactional
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
val curation = curationRepository.findById(request.id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
request.title?.let { curation.title = it }
request.isAdult?.let { curation.isAdult = it }
@@ -44,7 +44,7 @@ class CharacterCurationAdminService(
@Transactional
fun softDelete(curationId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
curation.isActive = false
curationRepository.save(curation)
}
@@ -53,7 +53,7 @@ class CharacterCurationAdminService(
fun reorder(ids: List<Long>) {
ids.forEachIndexed { index, id ->
val curation = curationRepository.findById(id)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
curation.sortOrder = index + 1
curationRepository.save(curation)
}
@@ -61,14 +61,14 @@ class CharacterCurationAdminService(
@Transactional
fun addCharacters(curationId: Long, characterIds: List<Long>) {
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
if (characterIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty")
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
if (!curation.isActive) throw SodaException(messageKey = "admin.chat.curation.inactive")
val uniqueIds = characterIds.filter { it > 0 }.distinct()
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
if (uniqueIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.invalid_character_ids")
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
@@ -101,23 +101,23 @@ class CharacterCurationAdminService(
@Transactional
fun removeCharacter(curationId: Long, characterId: Long) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
val mappings = mappingRepository.findByCuration(curation)
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
?: throw SodaException(messageKey = "admin.chat.curation.mapping_not_found")
mappingRepository.delete(target)
}
@Transactional
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
val mappings = mappingRepository.findByCuration(curation)
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
characterIds.forEachIndexed { index, cid ->
val mapping = mappingByCharacterId[cid]
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
?: throw SodaException(messageKey = "admin.chat.curation.character_not_in_curation")
mapping.sortOrder = index + 1
mappingRepository.save(mapping)
}
@@ -146,7 +146,7 @@ class CharacterCurationAdminService(
@Transactional(readOnly = true)
fun listCharacters(curationId: Long): List<ChatCharacter> {
val curation = curationRepository.findById(curationId)
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
return mappings.map { it.chatCharacter }
}

View File

@@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
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.utils.ImageBlurUtil
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
@@ -34,6 +36,8 @@ class AdminCharacterImageController(
private val imageService: CharacterImageService,
private val s3Uploader: S3Uploader,
private val imageCloudFront: ImageContentCloudFront,
private val langContext: LangContext,
private val messageSource: SodaMessageSource,
@Value("\${cloud.aws.s3.content-bucket}")
private val s3Bucket: String,
@@ -106,14 +110,18 @@ class AdminCharacterImageController(
@DeleteMapping("/{imageId}")
fun delete(@PathVariable imageId: Long) = run {
imageService.deleteImage(imageId)
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
val message = messageSource.getMessage("admin.chat.character.image_deleted", langContext.lang)
ApiResponse.ok(null, message)
}
@PutMapping("/orders")
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
if (request.characterId == null) {
throw SodaException(messageKey = "admin.chat.character.character_id_required")
}
imageService.updateOrders(request.characterId, request.ids)
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
val message = messageSource.getMessage("admin.chat.character.order_updated", langContext.lang)
ApiResponse.ok(null, message)
}
private fun buildS3Key(characterId: Long): String {
@@ -132,7 +140,7 @@ class AdminCharacterImageController(
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
throw SodaException(messageKey = "admin.chat.character.image_save_failed")
}
}
@@ -141,7 +149,7 @@ class AdminCharacterImageController(
// 멀티파트를 BufferedImage로 읽기
val bytes = image.bytes
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
?: throw SodaException("이미지 포맷을 인식할 수 없습니다.")
?: throw SodaException(messageKey = "admin.chat.character.image_format_invalid")
val blurred = ImageBlurUtil.blurFast(bimg)
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
@@ -164,7 +172,7 @@ class AdminCharacterImageController(
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
throw SodaException(messageKey = "admin.chat.character.blur_image_save_failed")
}
}
}

View File

@@ -58,7 +58,7 @@ class AdminChatCharacterService(
@Transactional(readOnly = true)
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
val chatCharacter = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
}

View File

@@ -192,8 +192,8 @@ class AdminOriginalWorkController(
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
} catch (_: Exception) {
throw SodaException(messageKey = "admin.chat.original.image_save_failed")
}
}
}

View File

@@ -38,7 +38,7 @@ class AdminOriginalWorkService(
@Transactional
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
throw SodaException(messageKey = "admin.chat.original.duplicate_title")
}
val entity = OriginalWork(
title = request.title,
@@ -107,7 +107,7 @@ class AdminOriginalWorkService(
@Transactional
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
request.title?.let { ow.title = it }
request.contentType?.let { ow.contentType = it }
@@ -177,7 +177,7 @@ class AdminOriginalWorkService(
@Transactional
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
ow.imagePath = imagePath
return originalWorkRepository.save(ow)
}
@@ -186,7 +186,7 @@ class AdminOriginalWorkService(
@Transactional
fun deleteOriginalWork(id: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
ow.isDeleted = true
originalWorkRepository.save(ow)
}
@@ -195,7 +195,7 @@ class AdminOriginalWorkService(
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
}
/** 원작 페이징 조회 */
@@ -216,7 +216,7 @@ class AdminOriginalWorkService(
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
@@ -238,7 +238,7 @@ class AdminOriginalWorkService(
@Transactional
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = ow }
@@ -250,7 +250,7 @@ class AdminOriginalWorkService(
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
// 원작 존재 확인 (소프트 삭제 제외)
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = null }
@@ -261,13 +261,13 @@ class AdminOriginalWorkService(
@Transactional
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
val character = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
if (originalWorkId == 0L) {
character.originalWork = null
} else {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
character.originalWork = ow
}