test #369
37
AGENTS.md
Normal file
37
AGENTS.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
> 이 문서는 본 저장소에서 **AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙)**이다.
|
||||||
|
> 모든 신규 코드는 본 문서를 최우선 기준으로 작성한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 전제
|
||||||
|
질문에 대한 답변과 설명은 한국어로 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Commit Standards
|
||||||
|
|
||||||
|
1. 커밋 메시지는 **반드시 한국어로 작성한다.**
|
||||||
|
2. 제목(subject)은 **현재형**으로 작성한다. (예: “기능 추가”, “기능 추가함” 금지)
|
||||||
|
3. 제목은 **50자 이내**로 작성한다.
|
||||||
|
4. 제목과 본문 사이에는 **반드시 한 줄 공백**을 둔다.
|
||||||
|
5. 본문은 **한 줄당 72자 이내**로 작성한다.
|
||||||
|
6. 하나의 문단에서는 72자를 초과할 때만 줄바꿈한다.
|
||||||
|
7. **공개 API 변경 사항만 설명**하며, 패키지 프라이빗 구현 상세는 포함하지 않는다.
|
||||||
|
8. **테스트 코드에 대한 언급은 커밋 메시지에 포함하지 않는다.**
|
||||||
|
9. 제목에 `fix:`, `feat:`, `docs:` 등의 **접두어를 사용하지 않는다.**
|
||||||
|
10. 제목은 **첫 글자를 대문자로 시작**한다. 단, 함수명 등 소문자가 합당한 경우만 예외를 허용한다.
|
||||||
|
11. 도구 광고, 브랜딩, 홍보성 문구를 **절대 포함하지 않는다.**
|
||||||
|
12. 커밋 전에는 **반드시 파일을 개별 stage 한다.**
|
||||||
|
13. 커밋 전 **`work/scripts/check-commit-message-rules.sh` 검증을 통과해야 한다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. AI 사용 규칙 (AI Interaction Rules)
|
||||||
|
|
||||||
|
- 매우 작은 단위의 변경만 수행한다.
|
||||||
|
- 대규모 리팩터링은 반드시 사전 승인을 요청한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
✅ 본 문서는 **AI와 사람이 동일한 개발 규칙을 공유하기 위한 최상위 기준 문서**이며,
|
||||||
|
✅ 모든 신규 코드는 본 문서를 기준으로 검토된다.
|
||||||
@@ -18,7 +18,7 @@ version = "0.0.1-SNAPSHOT"
|
|||||||
val querydslVersion = "5.0.0"
|
val querydslVersion = "5.0.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -89,7 +89,7 @@ allOpen {
|
|||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += "-Xjsr305=strict"
|
freeCompilerArgs += "-Xjsr305=strict"
|
||||||
jvmTarget = "11"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
gradle.properties
Normal file
9
gradle.properties
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Gradle ?? JVM(daemon/worker) ?
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
|
# Kotlin ??? ?? ? (?? ???? ??)
|
||||||
|
kotlin.daemon.jvmargs=-Xmx2048m
|
||||||
|
|
||||||
|
# CI ???(?? ?? ??? ??? ?? ? ??)
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
org.gradle.parallel=false
|
||||||
@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -22,6 +24,8 @@ class AdminAuditionService(
|
|||||||
private val repository: AdminAuditionRepository,
|
private val repository: AdminAuditionRepository,
|
||||||
private val roleRepository: AdminAuditionRoleRepository,
|
private val roleRepository: AdminAuditionRoleRepository,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
@@ -44,7 +48,7 @@ class AdminAuditionService(
|
|||||||
fun updateAudition(image: MultipartFile?, requestString: String) {
|
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||||
val audition = repository.findByIdOrNull(id = request.id)
|
val audition = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
audition.title = request.title
|
audition.title = request.title
|
||||||
@@ -63,7 +67,7 @@ class AdminAuditionService(
|
|||||||
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||||
request.status == AuditionStatus.NOT_STARTED
|
request.status == AuditionStatus.NOT_STARTED
|
||||||
) {
|
) {
|
||||||
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
throw SodaException(messageKey = "admin.audition.status_cannot_revert")
|
||||||
}
|
}
|
||||||
|
|
||||||
audition.status = request.status
|
audition.status = request.status
|
||||||
@@ -88,11 +92,14 @@ class AdminAuditionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
||||||
|
val title = messageSource.getMessage("admin.audition.fcm.title.new", langContext.lang).orEmpty()
|
||||||
|
val messageTemplate = messageSource.getMessage("admin.audition.fcm.message.new", langContext.lang).orEmpty()
|
||||||
|
val message = String.format(messageTemplate, audition.title)
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||||
title = "새로운 오디션 등록!",
|
title = title,
|
||||||
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
message = message,
|
||||||
isAuth = audition.isAdult,
|
isAuth = audition.isAdult,
|
||||||
auditionId = audition.id ?: -1
|
auditionId = audition.id ?: -1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ data class CreateAuditionRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (title.isBlank()) {
|
if (title.isBlank()) {
|
||||||
throw SodaException("오디션 제목을 입력하세요")
|
throw SodaException(messageKey = "admin.audition.title_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
if (information.isBlank() || information.length < 10) {
|
||||||
throw SodaException("오디션 정보는 최소 10글자 입니다")
|
throw SodaException(messageKey = "admin.audition.information_min_length")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class AdminAuditionApplicantService(private val repository: AdminAuditionApplica
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteAuditionApplicant(id: Long) {
|
fun deleteAuditionApplicant(id: Long) {
|
||||||
val applicant = repository.findByIdOrNull(id)
|
val applicant = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
applicant.isActive = false
|
applicant.isActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class AdminAuditionRoleService(
|
|||||||
auditionScriptUrl = request.auditionScriptUrl
|
auditionScriptUrl = request.auditionScriptUrl
|
||||||
)
|
)
|
||||||
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||||
auditionRole.audition = audition
|
auditionRole.audition = audition
|
||||||
repository.save(auditionRole)
|
repository.save(auditionRole)
|
||||||
|
|
||||||
@@ -48,15 +48,19 @@ class AdminAuditionRoleService(
|
|||||||
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||||
val auditionRole = repository.findByIdOrNull(id = request.id)
|
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||||
|
|
||||||
if (!request.name.isNullOrBlank()) {
|
if (!request.name.isNullOrBlank()) {
|
||||||
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
if (request.name.length < 2) {
|
||||||
|
throw SodaException(messageKey = "admin.audition.role.name_min_length")
|
||||||
|
}
|
||||||
auditionRole.name = request.name
|
auditionRole.name = request.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.information.isNullOrBlank()) {
|
if (!request.information.isNullOrBlank()) {
|
||||||
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
if (request.information.length < 10) {
|
||||||
|
throw SodaException(messageKey = "admin.audition.role.information_min_length")
|
||||||
|
}
|
||||||
auditionRole.information = request.information
|
auditionRole.information = request.information
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ data class CreateAuditionRoleRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (auditionId < 0) {
|
if (auditionId < 0) {
|
||||||
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
throw SodaException(messageKey = "admin.audition.role.audition_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.isBlank() || name.length < 2) {
|
if (name.isBlank() || name.length < 2) {
|
||||||
throw SodaException("캐릭터명을 입력하세요")
|
throw SodaException(messageKey = "admin.audition.role.name_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||||
throw SodaException("오디션 대본 URL을 입력하세요")
|
throw SodaException(messageKey = "admin.audition.role.script_url_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
if (information.isBlank() || information.length < 10) {
|
||||||
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
|
throw SodaException(messageKey = "admin.audition.role.information_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ data class UpdateAuditionRoleRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (id < 0) {
|
if (id < 0) {
|
||||||
throw SodaException("잘못된 요청입니다.")
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ class CreatorSettlementRatioService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||||
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||||
}
|
}
|
||||||
|
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
@@ -43,12 +43,12 @@ class CreatorSettlementRatioService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
if (creator.role != MemberRole.CREATOR) {
|
||||||
throw SodaException("잘못된 크리에이터 입니다.")
|
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||||
}
|
}
|
||||||
val existing = repository.findByMemberId(request.memberId)
|
val existing = repository.findByMemberId(request.memberId)
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
||||||
existing.restore()
|
existing.restore()
|
||||||
existing.updateValues(
|
existing.updateValues(
|
||||||
request.subsidy,
|
request.subsidy,
|
||||||
@@ -62,7 +62,7 @@ class CreatorSettlementRatioService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||||
val existing = repository.findByMemberId(memberId)
|
val existing = repository.findByMemberId(memberId)
|
||||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
||||||
existing.softDelete()
|
existing.softDelete()
|
||||||
repository.save(existing)
|
repository.save(existing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,21 +33,21 @@ class AdminCanService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCan(id: Long) {
|
fun deleteCan(id: Long) {
|
||||||
val can = repository.findByIdOrNull(id)
|
val can = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
can.status = CanStatus.END_OF_SALE
|
can.status = CanStatus.END_OF_SALE
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun charge(request: AdminCanChargeRequest) {
|
fun charge(request: AdminCanChargeRequest) {
|
||||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
if (request.can <= 0) throw SodaException(messageKey = "admin.can.min_amount")
|
||||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
if (request.method.isBlank()) throw SodaException(messageKey = "admin.can.method_required")
|
||||||
|
|
||||||
val ids = request.memberIds.distinct()
|
val ids = request.memberIds.distinct()
|
||||||
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
|
if (ids.isEmpty()) throw SodaException(messageKey = "admin.can.member_ids_required")
|
||||||
|
|
||||||
val members = memberRepository.findAllById(ids).toList()
|
val members = memberRepository.findAllById(ids).toList()
|
||||||
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
|
if (members.size != ids.size) throw SodaException(messageKey = "admin.can.invalid_member_ids")
|
||||||
|
|
||||||
members.forEach { member ->
|
members.forEach { member ->
|
||||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||||
|
|||||||
@@ -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.chat.character.service.ChatCharacterBannerService
|
||||||
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.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
@@ -35,6 +37,8 @@ class AdminChatBannerController(
|
|||||||
private val bannerService: ChatCharacterBannerService,
|
private val bannerService: ChatCharacterBannerService,
|
||||||
private val adminCharacterService: AdminChatCharacterService,
|
private val adminCharacterService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val s3Bucket: String,
|
private val s3Bucket: String,
|
||||||
@@ -158,8 +162,8 @@ class AdminChatBannerController(
|
|||||||
filePath = "characters/banners/$bannerId/$fileName",
|
filePath = "characters/banners/$bannerId/$fileName",
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.chat.banner.image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +212,8 @@ class AdminChatBannerController(
|
|||||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||||
bannerService.deleteBanner(bannerId)
|
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 {
|
) = run {
|
||||||
bannerService.updateBannerOrders(request.ids)
|
bannerService.updateBannerOrders(request.ids)
|
||||||
|
|
||||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
val message = messageSource.getMessage("admin.chat.banner.reorder_success", langContext.lang)
|
||||||
|
ApiResponse.ok(null, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ class AdminChatCalculateService(
|
|||||||
val todayKst = LocalDate.now(kstZone)
|
val todayKst = LocalDate.now(kstZone)
|
||||||
|
|
||||||
if (endDate.isAfter(todayKst)) {
|
if (endDate.isAfter(todayKst)) {
|
||||||
throw SodaException("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
throw SodaException(messageKey = "admin.chat.calculate.end_date_max_today")
|
||||||
}
|
}
|
||||||
if (startDate.isAfter(endDate)) {
|
if (startDate.isAfter(endDate)) {
|
||||||
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
throw SodaException(messageKey = "admin.chat.calculate.start_date_after_end")
|
||||||
}
|
}
|
||||||
if (endDate.isAfter(startDate.plusMonths(6))) {
|
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||||
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
throw SodaException(messageKey = "admin.chat.calculate.max_period_6_months")
|
||||||
}
|
}
|
||||||
|
|
||||||
val startUtc = startDateStr.convertLocalDateTime()
|
val startUtc = startDateStr.convertLocalDateTime()
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType
|
|||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
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.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
|
|||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -118,7 +124,7 @@ class AdminChatCharacterController(
|
|||||||
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||||
val existingCharacter = service.findByName(request.name)
|
val existingCharacter = service.findByName(request.name)
|
||||||
if (existingCharacter != null) {
|
if (existingCharacter != null) {
|
||||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
throw SodaException(messageKey = "admin.chat.character.duplicate_name")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 외부 API 호출
|
// 1. 외부 API 호출
|
||||||
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
|
|||||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
||||||
|
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
query = chatCharacter.description,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +233,18 @@ class AdminChatCharacterController(
|
|||||||
|
|
||||||
// success가 false이면 throw
|
// success가 false이면 throw
|
||||||
if (!apiResponse.success) {
|
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 반환
|
// 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) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
throw SodaException(messageKey = "admin.chat.character.register_failed_retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +261,7 @@ class AdminChatCharacterController(
|
|||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.chat.character.image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,19 +301,19 @@ class AdminChatCharacterController(
|
|||||||
request.originalWorkId != null
|
request.originalWorkId != null
|
||||||
|
|
||||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||||
throw SodaException("변경된 데이터가 없습니다.")
|
throw SodaException(messageKey = "admin.chat.character.no_changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||||
if (hasChangedData) {
|
if (hasChangedData) {
|
||||||
val chatCharacter = service.findById(request.id)
|
val chatCharacter = service.findById(request.id)
|
||||||
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
?: throw SodaException(messageKey = "admin.chat.character.not_found")
|
||||||
|
|
||||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||||
if (request.name != null && request.name != chatCharacter.name) {
|
if (request.name != null && request.name != chatCharacter.name) {
|
||||||
val existingCharacter = service.findByName(request.name)
|
val existingCharacter = service.findByName(request.name)
|
||||||
if (existingCharacter != null) {
|
if (existingCharacter != null) {
|
||||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
throw SodaException(messageKey = "admin.chat.character.duplicate_name")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +337,13 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.id,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
if (request.originalWorkId != null) {
|
if (request.originalWorkId != null) {
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
@@ -413,11 +442,15 @@ class AdminChatCharacterController(
|
|||||||
|
|
||||||
// success가 false이면 throw
|
// success가 false이면 throw
|
||||||
if (!apiResponse.success) {
|
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) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
throw SodaException(messageKey = "admin.chat.character.update_failed_retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class CharacterCurationAdminController(
|
|||||||
@RequestBody request: CharacterCurationAddCharacterRequest
|
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||||
): ApiResponse<Boolean> {
|
): ApiResponse<Boolean> {
|
||||||
val ids = request.characterIds.filter { it > 0 }.distinct()
|
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)
|
service.addCharacters(curationId, ids)
|
||||||
return ApiResponse.ok(true)
|
return ApiResponse.ok(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class CharacterCurationAdminService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||||
val curation = curationRepository.findById(request.id)
|
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.title?.let { curation.title = it }
|
||||||
request.isAdult?.let { curation.isAdult = it }
|
request.isAdult?.let { curation.isAdult = it }
|
||||||
@@ -44,7 +44,7 @@ class CharacterCurationAdminService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun softDelete(curationId: Long) {
|
fun softDelete(curationId: Long) {
|
||||||
val curation = curationRepository.findById(curationId)
|
val curation = curationRepository.findById(curationId)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
curation.isActive = false
|
curation.isActive = false
|
||||||
curationRepository.save(curation)
|
curationRepository.save(curation)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ class CharacterCurationAdminService(
|
|||||||
fun reorder(ids: List<Long>) {
|
fun reorder(ids: List<Long>) {
|
||||||
ids.forEachIndexed { index, id ->
|
ids.forEachIndexed { index, id ->
|
||||||
val curation = curationRepository.findById(id)
|
val curation = curationRepository.findById(id)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
curation.sortOrder = index + 1
|
curation.sortOrder = index + 1
|
||||||
curationRepository.save(curation)
|
curationRepository.save(curation)
|
||||||
}
|
}
|
||||||
@@ -61,14 +61,14 @@ class CharacterCurationAdminService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
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)
|
val curation = curationRepository.findById(curationId)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
if (!curation.isActive) throw SodaException(messageKey = "admin.chat.curation.inactive")
|
||||||
|
|
||||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
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)
|
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||||
@@ -101,23 +101,23 @@ class CharacterCurationAdminService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun removeCharacter(curationId: Long, characterId: Long) {
|
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||||
val curation = curationRepository.findById(curationId)
|
val curation = curationRepository.findById(curationId)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
val mappings = mappingRepository.findByCuration(curation)
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
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)
|
mappingRepository.delete(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||||
val curation = curationRepository.findById(curationId)
|
val curation = curationRepository.findById(curationId)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
val mappings = mappingRepository.findByCuration(curation)
|
val mappings = mappingRepository.findByCuration(curation)
|
||||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||||
|
|
||||||
characterIds.forEachIndexed { index, cid ->
|
characterIds.forEachIndexed { index, cid ->
|
||||||
val mapping = mappingByCharacterId[cid]
|
val mapping = mappingByCharacterId[cid]
|
||||||
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
?: throw SodaException(messageKey = "admin.chat.curation.character_not_in_curation")
|
||||||
mapping.sortOrder = index + 1
|
mapping.sortOrder = index + 1
|
||||||
mappingRepository.save(mapping)
|
mappingRepository.save(mapping)
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ class CharacterCurationAdminService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||||
val curation = curationRepository.findById(curationId)
|
val curation = curationRepository.findById(curationId)
|
||||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||||
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||||
return mappings.map { it.chatCharacter }
|
return mappings.map { it.chatCharacter }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.chat.character.image.CharacterImageService
|
||||||
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.utils.ImageBlurUtil
|
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -34,6 +36,8 @@ class AdminCharacterImageController(
|
|||||||
private val imageService: CharacterImageService,
|
private val imageService: CharacterImageService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val imageCloudFront: ImageContentCloudFront,
|
private val imageCloudFront: ImageContentCloudFront,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
private val s3Bucket: String,
|
private val s3Bucket: String,
|
||||||
@@ -106,14 +110,18 @@ class AdminCharacterImageController(
|
|||||||
@DeleteMapping("/{imageId}")
|
@DeleteMapping("/{imageId}")
|
||||||
fun delete(@PathVariable imageId: Long) = run {
|
fun delete(@PathVariable imageId: Long) = run {
|
||||||
imageService.deleteImage(imageId)
|
imageService.deleteImage(imageId)
|
||||||
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
val message = messageSource.getMessage("admin.chat.character.image_deleted", langContext.lang)
|
||||||
|
ApiResponse.ok(null, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/orders")
|
@PutMapping("/orders")
|
||||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
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)
|
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 {
|
private fun buildS3Key(characterId: Long): String {
|
||||||
@@ -132,7 +140,7 @@ class AdminCharacterImageController(
|
|||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.chat.character.image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +149,7 @@ class AdminCharacterImageController(
|
|||||||
// 멀티파트를 BufferedImage로 읽기
|
// 멀티파트를 BufferedImage로 읽기
|
||||||
val bytes = image.bytes
|
val bytes = image.bytes
|
||||||
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(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)
|
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||||
|
|
||||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||||
@@ -164,7 +172,7 @@ class AdminCharacterImageController(
|
|||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.chat.character.blur_image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class AdminChatCharacterService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||||
val chatCharacter = chatCharacterRepository.findById(characterId)
|
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||||
.orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
|
||||||
|
|
||||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ class AdminOriginalWorkController(
|
|||||||
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.chat.original.image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
|||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.Page
|
import org.springframework.data.domain.Page
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.data.domain.Sort
|
import org.springframework.data.domain.Sort
|
||||||
@@ -24,14 +29,16 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
class AdminOriginalWorkService(
|
class AdminOriginalWorkService(
|
||||||
private val originalWorkRepository: OriginalWorkRepository,
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
private val originalWorkTagRepository: OriginalWorkTagRepository
|
private val originalWorkTagRepository: OriginalWorkTagRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** 원작 등록 (중복 제목 방지 포함) */
|
/** 원작 등록 (중복 제목 방지 포함) */
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||||
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
|
throw SodaException(messageKey = "admin.chat.original.duplicate_title")
|
||||||
}
|
}
|
||||||
val entity = OriginalWork(
|
val entity = OriginalWork(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
@@ -56,14 +63,51 @@ class AdminOriginalWorkService(
|
|||||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return originalWorkRepository.save(entity)
|
|
||||||
|
val originalWork = originalWorkRepository.save(entity)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장이 완료된 후
|
||||||
|
* originalWork의
|
||||||
|
*
|
||||||
|
* languageCode == null이면 언어 감지 이벤트 호출
|
||||||
|
* languageCode != null이면 번역 이벤트 호출
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
if (originalWork.languageCode == null) {
|
||||||
|
val papagoQuery = listOf(
|
||||||
|
originalWork.title,
|
||||||
|
originalWork.contentType,
|
||||||
|
originalWork.category,
|
||||||
|
originalWork.description
|
||||||
|
)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" ")
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = originalWork.id!!,
|
||||||
|
query = papagoQuery,
|
||||||
|
targetType = LanguageDetectTargetType.ORIGINAL_WORK
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = originalWork.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalWork
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||||
@Transactional
|
@Transactional
|
||||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
|
|
||||||
request.title?.let { ow.title = it }
|
request.title?.let { ow.title = it }
|
||||||
request.contentType?.let { ow.contentType = it }
|
request.contentType?.let { ow.contentType = it }
|
||||||
@@ -107,6 +151,25 @@ class AdminOriginalWorkService(
|
|||||||
if (imagePath != null) {
|
if (imagePath != null) {
|
||||||
ow.imagePath = imagePath
|
ow.imagePath = imagePath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 이벤트 호출
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
request.title != null ||
|
||||||
|
request.contentType != null ||
|
||||||
|
request.category != null ||
|
||||||
|
request.description != null ||
|
||||||
|
request.tags != null
|
||||||
|
) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = ow.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return originalWorkRepository.save(ow)
|
return originalWorkRepository.save(ow)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +177,7 @@ class AdminOriginalWorkService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
ow.imagePath = imagePath
|
ow.imagePath = imagePath
|
||||||
return originalWorkRepository.save(ow)
|
return originalWorkRepository.save(ow)
|
||||||
}
|
}
|
||||||
@@ -123,7 +186,7 @@ class AdminOriginalWorkService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteOriginalWork(id: Long) {
|
fun deleteOriginalWork(id: Long) {
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
ow.isDeleted = true
|
ow.isDeleted = true
|
||||||
originalWorkRepository.save(ow)
|
originalWorkRepository.save(ow)
|
||||||
}
|
}
|
||||||
@@ -132,7 +195,7 @@ class AdminOriginalWorkService(
|
|||||||
@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 = "admin.chat.original.not_found") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 원작 페이징 조회 */
|
/** 원작 페이징 조회 */
|
||||||
@@ -153,7 +216,7 @@ class AdminOriginalWorkService(
|
|||||||
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||||
// 원작 존재 및 소프트 삭제 여부 확인
|
// 원작 존재 및 소프트 삭제 여부 확인
|
||||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.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 {
|
||||||
@@ -175,7 +238,7 @@ class AdminOriginalWorkService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
if (characterIds.isEmpty()) return
|
if (characterIds.isEmpty()) return
|
||||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
characters.forEach { it.originalWork = ow }
|
characters.forEach { it.originalWork = ow }
|
||||||
@@ -187,7 +250,7 @@ class AdminOriginalWorkService(
|
|||||||
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||||
// 원작 존재 확인 (소프트 삭제 제외)
|
// 원작 존재 확인 (소프트 삭제 제외)
|
||||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
if (characterIds.isEmpty()) return
|
if (characterIds.isEmpty()) return
|
||||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||||
characters.forEach { it.originalWork = null }
|
characters.forEach { it.originalWork = null }
|
||||||
@@ -198,13 +261,13 @@ class AdminOriginalWorkService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||||
val character = chatCharacterRepository.findById(characterId)
|
val character = chatCharacterRepository.findById(characterId)
|
||||||
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
|
||||||
|
|
||||||
if (originalWorkId == 0L) {
|
if (originalWorkId == 0L) {
|
||||||
character.originalWork = null
|
character.originalWork = null
|
||||||
} else {
|
} else {
|
||||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||||
character.originalWork = ow
|
character.originalWork = ow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class AdminContentService(
|
|||||||
searchWord: String,
|
searchWord: String,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): GetAdminContentListResponse {
|
): GetAdminContentListResponse {
|
||||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
if (searchWord.length < 2) {
|
||||||
|
throw SodaException(messageKey = "admin.content.search_word_min_length")
|
||||||
|
}
|
||||||
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
||||||
val audioContentAndThemeList = repository.getAudioContentList(
|
val audioContentAndThemeList = repository.getAudioContentList(
|
||||||
status = status,
|
status = status,
|
||||||
@@ -82,7 +84,7 @@ class AdminContentService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
||||||
val audioContent = repository.findByIdOrNull(id = request.id)
|
val audioContent = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("없는 콘텐츠 입니다.")
|
?: throw SodaException(messageKey = "admin.content.not_found")
|
||||||
|
|
||||||
if (request.isDefaultCoverImage) {
|
if (request.isDefaultCoverImage) {
|
||||||
audioContent.coverImage = "`profile/default_profile.png`"
|
audioContent.coverImage = "`profile/default_profile.png`"
|
||||||
|
|||||||
@@ -33,19 +33,19 @@ class AdminContentBannerService(
|
|||||||
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
||||||
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
||||||
throw SodaException("크리에이터를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
||||||
throw SodaException("시리즈를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.type == AudioContentBannerType.LINK && request.link == null) {
|
if (request.type == AudioContentBannerType.LINK && request.link == null) {
|
||||||
throw SodaException("링크 url을 입력하세요.")
|
throw SodaException(messageKey = "admin.content.banner.link_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
|
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
|
||||||
throw SodaException("이벤트를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = if (request.eventId != null && request.eventId > 0) {
|
val event = if (request.eventId != null && request.eventId > 0) {
|
||||||
@@ -94,7 +94,7 @@ class AdminContentBannerService(
|
|||||||
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
||||||
val audioContentBanner = repository.findByIdOrNull(request.id)
|
val audioContentBanner = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
val fileName = generateFileName()
|
val fileName = generateFileName()
|
||||||
@@ -124,22 +124,22 @@ class AdminContentBannerService(
|
|||||||
AudioContentBannerType.EVENT -> {
|
AudioContentBannerType.EVENT -> {
|
||||||
if (request.eventId != null) {
|
if (request.eventId != null) {
|
||||||
val event = eventRepository.findByIdOrNull(request.eventId)
|
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||||
?: throw SodaException("이벤트를 선택하세요.")
|
?: throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||||
|
|
||||||
audioContentBanner.event = event
|
audioContentBanner.event = event
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("이벤트를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContentBannerType.CREATOR -> {
|
AudioContentBannerType.CREATOR -> {
|
||||||
if (request.creatorId != null) {
|
if (request.creatorId != null) {
|
||||||
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||||
?: throw SodaException("크리에이터를 선택하세요.")
|
?: throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||||
|
|
||||||
audioContentBanner.creator = creator
|
audioContentBanner.creator = creator
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("크리에이터를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,18 +147,18 @@ class AdminContentBannerService(
|
|||||||
if (request.link != null) {
|
if (request.link != null) {
|
||||||
audioContentBanner.link = request.link
|
audioContentBanner.link = request.link
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("링크 url을 입력하세요.")
|
throw SodaException(messageKey = "admin.content.banner.link_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContentBannerType.SERIES -> {
|
AudioContentBannerType.SERIES -> {
|
||||||
if (request.seriesId != null) {
|
if (request.seriesId != null) {
|
||||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
?: throw SodaException("시리즈를 선택하세요.")
|
?: throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||||
|
|
||||||
audioContentBanner.series = series
|
audioContentBanner.series = series
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("시리즈를 선택하세요.")
|
throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AdminContentCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun createContentCuration(request: CreateContentCurationRequest) {
|
fun createContentCuration(request: CreateContentCurationRequest) {
|
||||||
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val curation = AudioContentCuration(
|
val curation = AudioContentCuration(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
@@ -37,7 +37,7 @@ class AdminContentCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
||||||
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
audioContentCuration.title = request.title
|
audioContentCuration.title = request.title
|
||||||
@@ -85,7 +85,7 @@ class AdminContentCurationService(
|
|||||||
|
|
||||||
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
||||||
val curation = repository.findByIdOrNull(curationId)
|
val curation = repository.findByIdOrNull(curationId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
return if (curation.isSeries) {
|
return if (curation.isSeries) {
|
||||||
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
||||||
@@ -106,7 +106,7 @@ class AdminContentCurationService(
|
|||||||
fun addItemToCuration(request: AddItemToCurationRequest) {
|
fun addItemToCuration(request: AddItemToCurationRequest) {
|
||||||
// 큐레이션 조회
|
// 큐레이션 조회
|
||||||
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (audioContentCuration.isSeries) {
|
if (audioContentCuration.isSeries) {
|
||||||
request.itemIdList.forEach { seriesId ->
|
request.itemIdList.forEach { seriesId ->
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class AdminHashTagCurationService(
|
|||||||
val isExists = repository.isExistsTag(tag = tag)
|
val isExists = repository.isExistsTag(tag = tag)
|
||||||
|
|
||||||
if (isExists) {
|
if (isExists) {
|
||||||
throw SodaException("이미 등록된 태그 입니다.")
|
throw SodaException(messageKey = "admin.content.hash_tag.already_registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.save(
|
repository.save(
|
||||||
@@ -42,7 +42,7 @@ class AdminHashTagCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
||||||
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (request.tag != null) {
|
if (request.tag != null) {
|
||||||
var tag = request.tag.trim()
|
var tag = request.tag.trim()
|
||||||
@@ -88,7 +88,7 @@ class AdminHashTagCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
||||||
val curation = repository.findByIdOrNull(id = request.curationId)
|
val curation = repository.findByIdOrNull(id = request.curationId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
request.itemIdList.forEach { contentId ->
|
request.itemIdList.forEach { contentId ->
|
||||||
val audioContent = audioContentRepository.findByIdAndActive(contentId)
|
val audioContent = audioContentRepository.findByIdAndActive(contentId)
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ class AdminContentSeriesService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifySeries(request: AdminModifySeriesRequest) {
|
fun modifySeries(request: AdminModifySeriesRequest) {
|
||||||
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (request.publishedDaysOfWeek != null) {
|
if (request.publishedDaysOfWeek != null) {
|
||||||
val days = request.publishedDaysOfWeek
|
val days = request.publishedDaysOfWeek
|
||||||
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
||||||
throw SodaException("랜덤과 연재요일 동시에 선택할 수 없습니다.")
|
throw SodaException(messageKey = "admin.content.series.random_days_conflict")
|
||||||
}
|
}
|
||||||
series.publishedDaysOfWeek.clear()
|
series.publishedDaysOfWeek.clear()
|
||||||
series.publishedDaysOfWeek.addAll(days)
|
series.publishedDaysOfWeek.addAll(days)
|
||||||
@@ -56,7 +56,7 @@ class AdminContentSeriesService(
|
|||||||
|
|
||||||
if (request.genreId != null) {
|
if (request.genreId != null) {
|
||||||
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
series.genre = genre
|
series.genre = genre
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|||||||
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.content.series.main.banner.ContentSeriesBannerService
|
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
@@ -33,6 +35,8 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
class AdminContentSeriesBannerController(
|
class AdminContentSeriesBannerController(
|
||||||
private val bannerService: ContentSeriesBannerService,
|
private val bannerService: ContentSeriesBannerService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val s3Bucket: String,
|
private val s3Bucket: String,
|
||||||
@@ -113,7 +117,8 @@ class AdminContentSeriesBannerController(
|
|||||||
@DeleteMapping("/{bannerId}")
|
@DeleteMapping("/{bannerId}")
|
||||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||||
bannerService.deleteBanner(bannerId)
|
bannerService.deleteBanner(bannerId)
|
||||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
val message = messageSource.getMessage("admin.content.series.banner.delete_success", langContext.lang)
|
||||||
|
ApiResponse.ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +129,8 @@ class AdminContentSeriesBannerController(
|
|||||||
@RequestBody request: UpdateBannerOrdersRequest
|
@RequestBody request: UpdateBannerOrdersRequest
|
||||||
) = run {
|
) = run {
|
||||||
bannerService.updateBannerOrders(request.ids)
|
bannerService.updateBannerOrders(request.ids)
|
||||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
val message = messageSource.getMessage("admin.content.series.banner.reorder_success", langContext.lang)
|
||||||
|
ApiResponse.ok(null, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||||
@@ -139,7 +145,7 @@ class AdminContentSeriesBannerController(
|
|||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
throw SodaException(messageKey = "admin.content.series.banner.image_save_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ class AdminContentSeriesGenreService(private val repository: AdminContentSeriesG
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifySeriesGenre(request: ModifySeriesGenreRequest) {
|
fun modifySeriesGenre(request: ModifySeriesGenreRequest) {
|
||||||
if (request.genre == null && request.isAdult == null && request.isActive == null) {
|
if (request.genre == null && request.isAdult == null && request.isActive == null) {
|
||||||
throw SodaException("변경사항이 없습니다.")
|
throw SodaException(messageKey = "admin.content.series.genre.no_changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
val seriesGenre = repository.findByIdOrNull(id = request.id)
|
val seriesGenre = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (request.genre != null) {
|
if (request.genre != null) {
|
||||||
seriesGenre.genre = request.genre
|
seriesGenre.genre = request.genre
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class AdminRecommendSeriesService(
|
|||||||
fun createRecommendSeries(image: MultipartFile, requestString: String) {
|
fun createRecommendSeries(image: MultipartFile, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
|
||||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val recommendSeries = RecommendSeries(isFree = request.isFree)
|
val recommendSeries = RecommendSeries(isFree = request.isFree)
|
||||||
recommendSeries.series = series
|
recommendSeries.series = series
|
||||||
@@ -49,7 +49,7 @@ class AdminRecommendSeriesService(
|
|||||||
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
|
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
|
||||||
val recommendSeries = repository.findByIdOrNull(request.id)
|
val recommendSeries = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
val fileName = generateFileName()
|
val fileName = generateFileName()
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -18,6 +21,8 @@ class AdminContentThemeService(
|
|||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val repository: AdminContentThemeRepository,
|
private val repository: AdminContentThemeRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
) {
|
) {
|
||||||
@@ -37,17 +42,26 @@ class AdminContentThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createTheme(theme: String, imagePath: String) {
|
fun createTheme(theme: String, imagePath: String) {
|
||||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = savedTheme.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||||
repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") }
|
repository.findIdByTheme(request.theme)?.let {
|
||||||
|
throw SodaException(messageKey = "admin.content.theme.already_registered")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTheme(id: Long) {
|
fun deleteTheme(id: Long) {
|
||||||
val theme = repository.findByIdOrNull(id)
|
val theme = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
theme.theme = "${theme.theme}_deleted"
|
theme.theme = "${theme.theme}_deleted"
|
||||||
theme.isActive = false
|
theme.isActive = false
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class AdminEventBannerService(
|
|||||||
startDateString: String,
|
startDateString: String,
|
||||||
endDateString: String
|
endDateString: String
|
||||||
): Long {
|
): Long {
|
||||||
if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요")
|
if (detail == null && link.isNullOrBlank()) {
|
||||||
|
throw SodaException(messageKey = "admin.event.banner.detail_or_link_required")
|
||||||
|
}
|
||||||
|
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||||
@@ -102,7 +104,7 @@ class AdminEventBannerService(
|
|||||||
event.detailImage = detailImagePath
|
event.detailImage = detailImagePath
|
||||||
event.popupImage = popupImagePath
|
event.popupImage = popupImagePath
|
||||||
|
|
||||||
return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.")
|
return event.id ?: throw SodaException(messageKey = "admin.event.banner.create_failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -118,10 +120,10 @@ class AdminEventBannerService(
|
|||||||
startDateString: String? = null,
|
startDateString: String? = null,
|
||||||
endDateString: String? = null
|
endDateString: String? = null
|
||||||
) {
|
) {
|
||||||
if (id <= 0) throw SodaException("잘못된 요청입니다.")
|
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val event = repository.findByIdOrNull(id)
|
val event = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (thumbnail != null) {
|
if (thumbnail != null) {
|
||||||
val metadata = ObjectMetadata()
|
val metadata = ObjectMetadata()
|
||||||
@@ -190,9 +192,9 @@ class AdminEventBannerService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun delete(id: Long) {
|
fun delete(id: Long) {
|
||||||
if (id <= 0) throw SodaException("잘못된 요청입니다.")
|
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
val event = repository.findByIdOrNull(id)
|
val event = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
event.isActive = false
|
event.isActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
|
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
|
||||||
val chargeEvent = repository.findByIdOrNull(request.id)
|
val chargeEvent = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "admin.charge_event.not_found_retry")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
chargeEvent.title = request.title
|
chargeEvent.title = request.title
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class AdminExplorerService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
|
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
|
||||||
if (request.title.isBlank()) throw SodaException("제목을 입력하세요.")
|
if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.title_required")
|
||||||
|
|
||||||
val findExplorerSection = repository.findByTitle(request.title)
|
val findExplorerSection = repository.findByTitle(request.title)
|
||||||
if (findExplorerSection != null) throw SodaException("동일한 제목이 있습니다.")
|
if (findExplorerSection != null) throw SodaException(messageKey = "admin.explorer.title_duplicate")
|
||||||
|
|
||||||
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
|
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
|
||||||
explorerSection.coloredTitle = request.coloredTitle
|
explorerSection.coloredTitle = request.coloredTitle
|
||||||
@@ -37,7 +37,7 @@ class AdminExplorerService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tags.size <= 0) throw SodaException("관심사를 선택하세요.")
|
if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_required")
|
||||||
explorerSection.tags = tags
|
explorerSection.tags = tags
|
||||||
|
|
||||||
return repository.save(explorerSection).id!!
|
return repository.save(explorerSection).id!!
|
||||||
@@ -53,14 +53,14 @@ class AdminExplorerService(
|
|||||||
request.coloredTitle == null &&
|
request.coloredTitle == null &&
|
||||||
request.isActive == null
|
request.isActive == null
|
||||||
) {
|
) {
|
||||||
throw SodaException("변경사항이 없습니다.")
|
throw SodaException(messageKey = "admin.explorer.no_changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
val explorerSection = repository.findByIdOrNull(request.id)
|
val explorerSection = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("해당하는 섹션이 없습니다.")
|
?: throw SodaException(messageKey = "admin.explorer.section_not_found")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
if (request.title.isBlank()) throw SodaException("올바른 제목을 입력하세요.")
|
if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.valid_title_required")
|
||||||
explorerSection.title = request.title
|
explorerSection.title = request.title
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ class AdminExplorerService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tags.size <= 0) throw SodaException("관심사를 입력하세요.")
|
if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_input_required")
|
||||||
if (tags != explorerSection.tags) {
|
if (tags != explorerSection.tags) {
|
||||||
explorerSection.tags.clear()
|
explorerSection.tags.clear()
|
||||||
explorerSection.tags.addAll(tags)
|
explorerSection.tags.addAll(tags)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
||||||
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
|
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
|
||||||
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
||||||
@@ -49,6 +51,8 @@ class AdminLiveService(
|
|||||||
private val canRepository: CanRepository,
|
private val canRepository: CanRepository,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String,
|
private val bucket: String,
|
||||||
@@ -118,10 +122,10 @@ class AdminLiveService(
|
|||||||
endDateString: String,
|
endDateString: String,
|
||||||
isAdult: Boolean
|
isAdult: Boolean
|
||||||
): Long {
|
): Long {
|
||||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
?: throw SodaException(messageKey = "admin.live.creator_required")
|
||||||
|
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||||
@@ -134,15 +138,15 @@ class AdminLiveService(
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.")
|
if (startDate < nowDate) throw SodaException(messageKey = "admin.live.start_after_now")
|
||||||
|
|
||||||
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
|
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
|
||||||
.atZone(ZoneId.of("Asia/Seoul"))
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.")
|
if (endDate < nowDate) throw SodaException(messageKey = "admin.live.end_after_now")
|
||||||
if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end")
|
||||||
|
|
||||||
val recommendCreatorBanner = RecommendLiveCreatorBanner(
|
val recommendCreatorBanner = RecommendLiveCreatorBanner(
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
@@ -176,13 +180,13 @@ class AdminLiveService(
|
|||||||
isAdult: Boolean?
|
isAdult: Boolean?
|
||||||
) {
|
) {
|
||||||
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
|
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
|
||||||
?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.")
|
?: throw SodaException(messageKey = "admin.live.recommend_not_found_retry")
|
||||||
|
|
||||||
if (creatorId != null) {
|
if (creatorId != null) {
|
||||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
?: throw SodaException(messageKey = "admin.live.creator_required")
|
||||||
|
|
||||||
recommendCreatorBanner.creator = creator
|
recommendCreatorBanner.creator = creator
|
||||||
}
|
}
|
||||||
@@ -218,13 +222,13 @@ class AdminLiveService(
|
|||||||
|
|
||||||
if (endDate != null) {
|
if (endDate != null) {
|
||||||
if (endDate <= startDate) {
|
if (endDate <= startDate) {
|
||||||
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
throw SodaException(messageKey = "admin.live.start_before_end")
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendCreatorBanner.endDate = endDate
|
recommendCreatorBanner.endDate = endDate
|
||||||
} else {
|
} else {
|
||||||
if (recommendCreatorBanner.endDate <= startDate) {
|
if (recommendCreatorBanner.endDate <= startDate) {
|
||||||
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
throw SodaException(messageKey = "admin.live.start_before_end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +241,7 @@ class AdminLiveService(
|
|||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (endDate <= recommendCreatorBanner.startDate) {
|
if (endDate <= recommendCreatorBanner.startDate) {
|
||||||
throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.")
|
throw SodaException(messageKey = "admin.live.end_after_start")
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendCreatorBanner.endDate = endDate
|
recommendCreatorBanner.endDate = endDate
|
||||||
@@ -266,7 +270,10 @@ class AdminLiveService(
|
|||||||
for (room in findRoomList) {
|
for (room in findRoomList) {
|
||||||
room.isActive = false
|
room.isActive = false
|
||||||
|
|
||||||
val roomCancel = LiveRoomCancel("관리자에 의한 취소 - 노쇼")
|
val cancelReason = messageSource
|
||||||
|
.getMessage("admin.live.cancel_reason.no_show", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
val roomCancel = LiveRoomCancel(cancelReason)
|
||||||
roomCancel.room = room
|
roomCancel.room = room
|
||||||
roomCancelRepository.save(roomCancel)
|
roomCancelRepository.save(roomCancel)
|
||||||
|
|
||||||
@@ -286,7 +293,10 @@ class AdminLiveService(
|
|||||||
it.status = UseCanCalculateStatus.REFUND
|
it.status = UseCanCalculateStatus.REFUND
|
||||||
|
|
||||||
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
||||||
charge.title = "${it.can} 캔"
|
val canTitleTemplate = messageSource
|
||||||
|
.getMessage("live.room.can_title", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
charge.title = String.format(canTitleTemplate, it.can)
|
||||||
charge.useCan = useCan
|
charge.useCan = useCan
|
||||||
|
|
||||||
when (it.paymentGateway) {
|
when (it.paymentGateway) {
|
||||||
@@ -300,7 +310,9 @@ class AdminLiveService(
|
|||||||
status = PaymentStatus.COMPLETE,
|
status = PaymentStatus.COMPLETE,
|
||||||
paymentGateway = it.paymentGateway
|
paymentGateway = it.paymentGateway
|
||||||
)
|
)
|
||||||
payment.method = "환불"
|
payment.method = messageSource
|
||||||
|
.getMessage("live.room.refund_method", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -313,11 +325,15 @@ class AdminLiveService(
|
|||||||
reservationRepository.cancelReservation(roomId = room.id!!)
|
reservationRepository.cancelReservation(roomId = room.id!!)
|
||||||
|
|
||||||
// 라이브 취소 푸시 발송
|
// 라이브 취소 푸시 발송
|
||||||
|
val cancelMessageTemplate = messageSource
|
||||||
|
.getMessage("live.room.fcm.message.canceled", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
val cancelMessage = String.format(cancelMessageTemplate, room.title)
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CANCEL_LIVE,
|
type = FcmEventType.CANCEL_LIVE,
|
||||||
title = room.member!!.nickname,
|
title = room.member!!.nickname,
|
||||||
message = "라이브 취소 : ${room.title}",
|
message = cancelMessage,
|
||||||
recipientsMap = pushTokenListMap
|
recipientsMap = pushTokenListMap
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.admin.live.signature
|
|||||||
|
|
||||||
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.live.signature.SignatureCanSortType
|
import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
@@ -16,7 +18,11 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
@RestController
|
@RestController
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@RequestMapping("/admin/live/signature-can")
|
@RequestMapping("/admin/live/signature-can")
|
||||||
class AdminSignatureCanController(private val service: AdminSignatureCanService) {
|
class AdminSignatureCanController(
|
||||||
|
private val service: AdminSignatureCanService,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext
|
||||||
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getSignatureCanList(
|
fun getSignatureCanList(
|
||||||
pageable: Pageable,
|
pageable: Pageable,
|
||||||
@@ -32,7 +38,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService)
|
|||||||
@RequestParam("isAdult", required = false) isAdult: Boolean = false
|
@RequestParam("isAdult", required = false) isAdult: Boolean = false
|
||||||
) = ApiResponse.ok(
|
) = ApiResponse.ok(
|
||||||
service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult),
|
service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult),
|
||||||
"등록되었습니다."
|
messageSource.getMessage("admin.signature_can.created", langContext.lang)
|
||||||
)
|
)
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@@ -45,7 +51,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService)
|
|||||||
@RequestParam("isAdult", required = false) isAdult: Boolean?
|
@RequestParam("isAdult", required = false) isAdult: Boolean?
|
||||||
) = run {
|
) = run {
|
||||||
if (can == null && time == null && image == null && isActive == null && isAdult == null) {
|
if (can == null && time == null && image == null && isActive == null && isAdult == null) {
|
||||||
throw SodaException("변경사항이 없습니다.")
|
throw SodaException(messageKey = "admin.signature_can.no_changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
@@ -57,7 +63,7 @@ class AdminSignatureCanController(private val service: AdminSignatureCanService)
|
|||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
),
|
),
|
||||||
"수정되었습니다."
|
messageSource.getMessage("admin.signature_can.updated", langContext.lang)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ class AdminSignatureCanService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) {
|
fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) {
|
||||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
if (creatorId < 1) throw SodaException(messageKey = "admin.signature_can.creator_required")
|
||||||
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
||||||
if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.")
|
if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
?: throw SodaException(messageKey = "admin.signature_can.creator_required")
|
||||||
|
|
||||||
val signatureCan = SignatureCan(can = can, isAdult = isAdult)
|
val signatureCan = SignatureCan(can = can, isAdult = isAdult)
|
||||||
signatureCan.creator = creator
|
signatureCan.creator = creator
|
||||||
@@ -76,15 +76,15 @@ class AdminSignatureCanService(
|
|||||||
isAdult: Boolean?
|
isAdult: Boolean?
|
||||||
) {
|
) {
|
||||||
val signatureCan = repository.findByIdOrNull(id = id)
|
val signatureCan = repository.findByIdOrNull(id = id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (can != null) {
|
if (can != null) {
|
||||||
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
||||||
signatureCan.can = can
|
signatureCan.can = can
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time != null) {
|
if (time != null) {
|
||||||
if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.")
|
if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range")
|
||||||
signatureCan.time = time
|
signatureCan.time = time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepositor
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
||||||
val entity = repository.findByIdOrNull(request.id)
|
val entity = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("잘못된 접근입니다")
|
?: throw SodaException(messageKey = "admin.media_partner.invalid_access")
|
||||||
|
|
||||||
if (request.mediaGroup != null) {
|
if (request.mediaGroup != null) {
|
||||||
entity.mediaGroup = request.mediaGroup
|
entity.mediaGroup = request.mediaGroup
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.member
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
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 kr.co.vividnext.sodalive.member.MemberProvider
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -17,6 +19,8 @@ import java.time.format.DateTimeFormatter
|
|||||||
class AdminMemberService(
|
class AdminMemberService(
|
||||||
private val repository: AdminMemberRepository,
|
private val repository: AdminMemberRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
@@ -24,7 +28,7 @@ class AdminMemberService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateMember(request: UpdateMemberRequest) {
|
fun updateMember(request: UpdateMemberRequest) {
|
||||||
val member = repository.findByIdOrNull(request.id)
|
val member = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException("해당 유저가 없습니다.")
|
?: throw SodaException(messageKey = "admin.member.not_found")
|
||||||
|
|
||||||
if (member.role != request.userType) {
|
if (member.role != request.userType) {
|
||||||
member.role = request.userType
|
member.role = request.userType
|
||||||
@@ -44,7 +48,7 @@ class AdminMemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
||||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||||
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
|
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
|
||||||
val memberList = processMemberListToGetAdminMemberListResponseItemList(
|
val memberList = processMemberListToGetAdminMemberListResponseItemList(
|
||||||
memberList = repository.searchMember(
|
memberList = repository.searchMember(
|
||||||
@@ -71,7 +75,7 @@ class AdminMemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
||||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||||
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
|
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
|
||||||
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
|
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
|
||||||
memberList = repository.searchMember(
|
memberList = repository.searchMember(
|
||||||
@@ -92,18 +96,18 @@ class AdminMemberService(
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.map {
|
.map {
|
||||||
val userType = when (it.role) {
|
val userType = when (it.role) {
|
||||||
MemberRole.ADMIN -> "관리자"
|
MemberRole.ADMIN -> messageSource.getMessage("admin.member.role.admin", langContext.lang).orEmpty()
|
||||||
MemberRole.USER -> "일반회원"
|
MemberRole.USER -> messageSource.getMessage("admin.member.role.user", langContext.lang).orEmpty()
|
||||||
MemberRole.CREATOR -> "크리에이터"
|
MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
|
||||||
MemberRole.AGENT -> "에이전트"
|
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty()
|
||||||
MemberRole.BOT -> "봇"
|
MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
val loginType = when (it.provider) {
|
val loginType = when (it.provider) {
|
||||||
MemberProvider.EMAIL -> "이메일"
|
MemberProvider.EMAIL -> messageSource.getMessage("member.provider.email", langContext.lang).orEmpty()
|
||||||
MemberProvider.KAKAO -> "카카오"
|
MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty()
|
||||||
MemberProvider.GOOGLE -> "구글"
|
MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty()
|
||||||
MemberProvider.APPLE -> "애플"
|
MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
val signUpDate = it.createdAt!!
|
val signUpDate = it.createdAt!!
|
||||||
@@ -146,7 +150,7 @@ class AdminMemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
||||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||||
val limit = if (size <= 0) 20 else size
|
val limit = if (size <= 0) 20 else size
|
||||||
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
||||||
}
|
}
|
||||||
@@ -154,7 +158,7 @@ class AdminMemberService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun resetPassword(request: ResetPasswordRequest) {
|
fun resetPassword(request: ResetPasswordRequest) {
|
||||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||||
?: throw SodaException("잘못된 회원정보입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "admin.member.reset_password_invalid")
|
||||||
|
|
||||||
member.password = passwordEncoder.encode(member.email.split("@")[0])
|
member.password = passwordEncoder.encode(member.email.split("@")[0])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ class AdminMemberTagService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun tagExistCheck(request: CreateMemberTagRequest) {
|
private fun tagExistCheck(request: CreateMemberTagRequest) {
|
||||||
repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") }
|
repository.findByTag(request.tag)?.let {
|
||||||
|
throw SodaException(messageKey = "admin.member.tag.already_registered")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTag(tag: String, imagePath: String, isAdult: Boolean) {
|
private fun createTag(tag: String, imagePath: String, isAdult: Boolean) {
|
||||||
@@ -51,7 +53,7 @@ class AdminMemberTagService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTag(id: Long) {
|
fun deleteTag(id: Long) {
|
||||||
val creatorTag = repository.findByIdOrNull(id)
|
val creatorTag = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
creatorTag.tag = "${creatorTag.tag}_deleted"
|
creatorTag.tag = "${creatorTag.tag}_deleted"
|
||||||
creatorTag.isActive = false
|
creatorTag.isActive = false
|
||||||
@@ -60,7 +62,7 @@ class AdminMemberTagService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
|
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
|
||||||
val creatorTag = repository.findByIdOrNull(id)
|
val creatorTag = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
|
||||||
creatorTag.tag = request.tag
|
creatorTag.tag = request.tag
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PointPolicyService(private val repository: PointPolicyRepository) {
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
||||||
val pointPolicy = repository.findByIdOrNull(id)
|
val pointPolicy = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 접근입니다.")
|
?: throw SodaException(messageKey = "admin.point.policy.invalid_access")
|
||||||
|
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (dateRange == null) {
|
if (dateRange == null) {
|
||||||
throw SodaException("잘못된 접근입니다.")
|
throw SodaException(messageKey = "admin.member.statistics.invalid_access")
|
||||||
}
|
}
|
||||||
|
|
||||||
var startDateTime = startDate.atStartOfDay()
|
var startDateTime = startDate.atStartOfDay()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AlarmController(private val service: AlarmService) {
|
|||||||
fun getSlotQuantityAndPrice(
|
fun getSlotQuantityAndPrice(
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getSlotQuantityAndPrice(memberId = member.id!!)
|
service.getSlotQuantityAndPrice(memberId = member.id!!)
|
||||||
@@ -29,7 +29,7 @@ class AlarmController(private val service: AlarmService) {
|
|||||||
@PathVariable("container") container: String,
|
@PathVariable("container") container: String,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.buyExtraSlot(
|
service.buyExtraSlot(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class AlarmService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw SodaException("이미 구매하셨습니다")
|
throw SodaException(messageKey = "alarm.error.already_purchased")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
@@ -10,10 +12,13 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
|||||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -47,6 +52,12 @@ class HomeService(
|
|||||||
private val rankingRepository: RankingRepository,
|
private val rankingRepository: RankingRepository,
|
||||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
|
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@@ -111,6 +122,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||||
|
|
||||||
val eventBannerList = GetEventResponse(
|
val eventBannerList = GetEventResponse(
|
||||||
totalCount = 0,
|
totalCount = 0,
|
||||||
eventList = emptyList()
|
eventList = emptyList()
|
||||||
@@ -122,23 +135,28 @@ class HomeService(
|
|||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 오직 보이스온에서만
|
||||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
orderByRandom = true
|
orderByRandom = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
|
||||||
|
|
||||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||||
|
|
||||||
|
// 요일별 시리즈
|
||||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||||
)
|
)
|
||||||
|
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = characterService.getPopularCharacters()
|
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
val currentDateTime = LocalDateTime.now()
|
||||||
val startDate = currentDateTime
|
val startDate = currentDateTime
|
||||||
@@ -159,12 +177,64 @@ class HomeService(
|
|||||||
sort = ContentRankingSortType.REVENUE
|
sort = ContentRankingSortType.REVENUE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val contentRankingContentIds = contentRanking.map { it.contentId }
|
||||||
|
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
contentRanking.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentRanking
|
||||||
|
}
|
||||||
|
|
||||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recommendChannelList의 콘텐츠 번역 데이터 조회
|
||||||
|
*
|
||||||
|
* languageCode != null
|
||||||
|
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
|
||||||
|
*
|
||||||
|
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
|
||||||
|
*/
|
||||||
|
val channelContentIds = recommendChannelList
|
||||||
|
.flatMap { it.contentList }
|
||||||
|
.map { it.contentId }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
recommendChannelList.map { channel ->
|
||||||
|
val translatedContentList = channel.contentList.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.copy(contentList = translatedContentList)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recommendChannelList
|
||||||
|
}
|
||||||
|
|
||||||
val freeContentList = contentService.getLatestContentByTheme(
|
val freeContentList = contentService.getLatestContentByTheme(
|
||||||
theme = contentThemeService.getActiveThemeOfContent(
|
theme = contentThemeService.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -183,6 +253,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
||||||
|
|
||||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||||
theme = emptyList(),
|
theme = emptyList(),
|
||||||
@@ -199,6 +271,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||||
|
|
||||||
val curationList = curationService.getContentCurationList(
|
val curationList = curationService.getContentCurationList(
|
||||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -210,17 +284,17 @@ class HomeService(
|
|||||||
liveList = liveList,
|
liveList = liveList,
|
||||||
creatorRanking = creatorRanking,
|
creatorRanking = creatorRanking,
|
||||||
latestContentThemeList = latestContentThemeList,
|
latestContentThemeList = latestContentThemeList,
|
||||||
latestContentList = latestContentList,
|
latestContentList = translatedLatestContentList,
|
||||||
bannerList = bannerList,
|
bannerList = bannerList,
|
||||||
eventBannerList = eventBannerList,
|
eventBannerList = eventBannerList,
|
||||||
originalAudioDramaList = originalAudioDramaList,
|
originalAudioDramaList = translatedOriginalAudioDramaList,
|
||||||
auditionList = auditionList,
|
auditionList = auditionList,
|
||||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
|
||||||
popularCharacters = popularCharacters,
|
popularCharacters = translatedPopularCharacters,
|
||||||
contentRanking = contentRanking,
|
contentRanking = translatedContentRanking,
|
||||||
recommendChannelList = recommendChannelList,
|
recommendChannelList = translatedRecommendChannelList,
|
||||||
freeContentList = freeContentList,
|
freeContentList = translatedFreeContentList,
|
||||||
pointAvailableContentList = pointAvailableContentList,
|
pointAvailableContentList = translatedPointAvailableContentList,
|
||||||
recommendContentList = getRecommendContentList(
|
recommendContentList = getRecommendContentList(
|
||||||
isAdultContentVisible = isAdultContentVisible,
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
@@ -249,7 +323,7 @@ class HomeService(
|
|||||||
listOf(theme)
|
listOf(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return contentService.getLatestContentByTheme(
|
val contentList = contentService.getLatestContentByTheme(
|
||||||
theme = themeList,
|
theme = themeList,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
isFree = false,
|
isFree = false,
|
||||||
@@ -261,6 +335,8 @@ class HomeService(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getTranslatedContentList(contentList = contentList)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDayOfWeekSeriesList(
|
fun getDayOfWeekSeriesList(
|
||||||
@@ -272,12 +348,14 @@ class HomeService(
|
|||||||
val memberId = member?.id
|
val memberId = member?.id
|
||||||
val isAdult = member?.auth != null && isAdultContentVisible
|
val isAdult = member?.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
return seriesService.getDayOfWeekSeriesList(
|
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
dayOfWeek = dayOfWeek
|
dayOfWeek = dayOfWeek
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getContentRankingBySort(
|
fun getContentRankingBySort(
|
||||||
@@ -371,6 +449,114 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return getTranslatedContentList(contentList = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||||
|
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||||
|
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||||
|
*
|
||||||
|
* 성능:
|
||||||
|
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||||
|
*
|
||||||
|
* @param contentList 번역 대상 AudioContentMainItem 목록
|
||||||
|
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
|
||||||
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
|
||||||
|
return if (contentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
contentList.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||||
|
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||||
|
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||||
|
*
|
||||||
|
* 성능:
|
||||||
|
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||||
|
*
|
||||||
|
* @param seriesList 번역 대상 SeriesListItem 목록
|
||||||
|
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedSeriesList(
|
||||||
|
seriesList: List<GetSeriesListResponse.SeriesListItem>
|
||||||
|
): List<GetSeriesListResponse.SeriesListItem> {
|
||||||
|
val seriesIds = seriesList.map { it.seriesId }
|
||||||
|
|
||||||
|
return if (seriesIds.isNotEmpty()) {
|
||||||
|
val translations = seriesTranslationRepository
|
||||||
|
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.seriesId }
|
||||||
|
|
||||||
|
seriesList.map { item ->
|
||||||
|
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
seriesList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||||
|
* 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||||
|
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||||
|
*
|
||||||
|
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||||
|
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||||
|
val characterIds = aiCharacterList.map { it.characterId }
|
||||||
|
|
||||||
|
return if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
aiCharacterList.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||||
|
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName, description = translatedDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
aiCharacterList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AuditionController(private val service: AuditionService) {
|
|||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.getAuditionDetail(auditionId = id))
|
ApiResponse.ok(service.getAuditionDetail(auditionId = id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService)
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getAuditionApplicantList(
|
service.getAuditionApplicantList(
|
||||||
@@ -42,7 +42,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService)
|
|||||||
@RequestPart("request") requestString: String,
|
@RequestPart("request") requestString: String,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.applyAuditionRole(
|
service.applyAuditionRole(
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ class AuditionApplicantService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) {
|
fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) {
|
||||||
if (contentFile == null) throw SodaException("녹음 파일을 확인해 주세요.")
|
if (contentFile == null) throw SodaException(messageKey = "audition.applicant.content_file_required")
|
||||||
val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java)
|
val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java)
|
||||||
|
|
||||||
val auditionRole = roleRepository.findByIdOrNull(id = request.roleId)
|
val auditionRole = roleRepository.findByIdOrNull(id = request.roleId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
||||||
|
|
||||||
val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId(
|
val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId(
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AuditionRoleController(private val service: AuditionRoleService) {
|
|||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getAuditionRoleDetail(
|
service.getAuditionRoleDetail(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AuditionRoleService(
|
|||||||
) {
|
) {
|
||||||
fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse {
|
fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse {
|
||||||
val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
||||||
|
|
||||||
val isAlreadyApplicant = applicantRepository.isAlreadyApplicant(
|
val isAlreadyApplicant = applicantRepository.isAlreadyApplicant(
|
||||||
auditionRoleId = auditionRoleId,
|
auditionRoleId = auditionRoleId,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class AuditionVoteController(
|
|||||||
@RequestBody request: VoteAuditionApplicantRequest,
|
@RequestBody request: VoteAuditionApplicantRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.voteAuditionApplicant(
|
service.voteAuditionApplicant(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AuditionVoteService(
|
|||||||
) {
|
) {
|
||||||
fun voteAuditionApplicant(applicantId: Long, timezone: String, container: String, member: Member) {
|
fun voteAuditionApplicant(applicantId: Long, timezone: String, container: String, member: Member) {
|
||||||
val applicant = applicantRepository.findByIdOrNull(applicantId)
|
val applicant = applicantRepository.findByIdOrNull(applicantId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
||||||
|
|
||||||
val defaultZoneId = ZoneId.of("Asia/Seoul")
|
val defaultZoneId = ZoneId.of("Asia/Seoul")
|
||||||
val clientZoneId = try {
|
val clientZoneId = try {
|
||||||
@@ -43,7 +43,7 @@ class AuditionVoteService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (voteCount > 100) {
|
if (voteCount > 100) {
|
||||||
throw SodaException("오늘 응원은 여기까지!\n하루 최대 100회까지 응원이 가능합니다.\n내일 다시 이용해주세요.")
|
throw SodaException(messageKey = "audition.vote.max_daily_reached")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (voteCount > 0) {
|
if (voteCount > 0) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class CanController(private val service: CanService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.getCanStatus(member, container))
|
ApiResponse.ok(service.getCanStatus(member, container))
|
||||||
@@ -41,7 +41,7 @@ class CanController(private val service: CanService) {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container))
|
ApiResponse.ok(service.getCanUseStatus(member, pageable, timezone, container))
|
||||||
@@ -55,7 +55,7 @@ class CanController(private val service: CanService) {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container))
|
ApiResponse.ok(service.getCanChargeStatus(member, pageable, timezone, container))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.payverseCharge(member, request))
|
ApiResponse.ok(service.payverseCharge(member, request))
|
||||||
@@ -45,7 +45,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
val response = service.payverseVerify(memberId = member.id!!, verifyRequest)
|
||||||
@@ -83,7 +83,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.charge(member, chargeRequest))
|
ApiResponse.ok(service.charge(member, chargeRequest))
|
||||||
@@ -95,7 +95,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = service.verify(memberId = member.id!!, verifyRequest)
|
val response = service.verify(memberId = member.id!!, verifyRequest)
|
||||||
@@ -109,7 +109,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
val response = service.verifyHecto(memberId = member.id!!, verifyRequest)
|
||||||
@@ -123,7 +123,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.appleCharge(member, chargeRequest))
|
ApiResponse.ok(service.appleCharge(member, chargeRequest))
|
||||||
@@ -135,7 +135,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
val response = service.appleVerify(memberId = member.id!!, verifyRequest)
|
||||||
@@ -149,7 +149,7 @@ class ChargeController(
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.paymentGateway == PaymentGateway.GOOGLE_IAP) {
|
if (request.paymentGateway == PaymentGateway.GOOGLE_IAP) {
|
||||||
@@ -174,7 +174,7 @@ class ChargeController(
|
|||||||
trackingCharge(member, response)
|
trackingCharge(member, response)
|
||||||
ApiResponse.ok(Unit)
|
ApiResponse.ok(Unit)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
|||||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.google.GooglePlayService
|
import kr.co.vividnext.sodalive.google.GooglePlayService
|
||||||
|
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 kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.point.MemberPoint
|
import kr.co.vividnext.sodalive.point.MemberPoint
|
||||||
@@ -53,6 +55,8 @@ class ChargeService(
|
|||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
private val googlePlayService: GooglePlayService,
|
private val googlePlayService: GooglePlayService,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${bootpay.application-id}")
|
@Value("\${bootpay.application-id}")
|
||||||
private val bootpayApplicationId: String,
|
private val bootpayApplicationId: String,
|
||||||
@@ -174,10 +178,10 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
fun chargeByCoupon(couponNumber: String, member: Member): String {
|
||||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||||
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
?: throw SodaException(messageKey = "can.coupon.invalid_number_contact")
|
||||||
|
|
||||||
if (canCouponNumber.member != null) {
|
if (canCouponNumber.member != null) {
|
||||||
throw SodaException("이미 사용한 쿠폰번호 입니다.")
|
throw SodaException(messageKey = "can.coupon.already_used")
|
||||||
}
|
}
|
||||||
canCouponNumber.member = member
|
canCouponNumber.member = member
|
||||||
|
|
||||||
@@ -186,7 +190,7 @@ class ChargeService(
|
|||||||
when (coupon.couponType) {
|
when (coupon.couponType) {
|
||||||
CouponType.CAN -> {
|
CouponType.CAN -> {
|
||||||
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
val couponCharge = Charge(0, coupon.can, status = ChargeStatus.COUPON)
|
||||||
couponCharge.title = "${coupon.can} 캔"
|
couponCharge.title = formatMessage("can.charge.title", coupon.can)
|
||||||
couponCharge.member = member
|
couponCharge.member = member
|
||||||
|
|
||||||
val payment = Payment(
|
val payment = Payment(
|
||||||
@@ -198,7 +202,7 @@ class ChargeService(
|
|||||||
chargeRepository.save(couponCharge)
|
chargeRepository.save(couponCharge)
|
||||||
|
|
||||||
member.charge(0, coupon.can, "pg")
|
member.charge(0, coupon.can, "pg")
|
||||||
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}캔이 지급되었습니다."
|
return formatMessage("can.coupon.use_complete", coupon.can)
|
||||||
}
|
}
|
||||||
|
|
||||||
CouponType.POINT -> {
|
CouponType.POINT -> {
|
||||||
@@ -226,7 +230,7 @@ class ChargeService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return "쿠폰 사용이 완료되었습니다.\n${coupon.can}포인트가 지급되었습니다."
|
return formatMessage("can.coupon.use_complete_point", coupon.can)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +238,7 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
|
fun payverseCharge(member: Member, request: PayverseChargeRequest): PayverseChargeResponse {
|
||||||
val can = canRepository.findByIdOrNull(request.canId)
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "can.charge.invalid_request_restart")
|
||||||
|
|
||||||
val requestCurrency = can.currency
|
val requestCurrency = can.currency
|
||||||
val isKrw = requestCurrency == "KRW"
|
val isKrw = requestCurrency == "KRW"
|
||||||
@@ -304,9 +308,9 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
val isKrw = charge.can?.currency == "KRW"
|
val isKrw = charge.can?.currency == "KRW"
|
||||||
val mid = if (isKrw) {
|
val mid = if (isKrw) {
|
||||||
@@ -322,7 +326,7 @@ class ChargeService(
|
|||||||
|
|
||||||
// 결제수단 확인
|
// 결제수단 확인
|
||||||
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
if (charge.payment?.paymentGateway != PaymentGateway.PAYVERSE) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결제 상태에 따른 분기 처리
|
// 결제 상태에 따른 분기 처리
|
||||||
@@ -339,10 +343,11 @@ class ChargeService(
|
|||||||
|
|
||||||
val response = okHttpClient.newCall(request).execute()
|
val response = okHttpClient.newCall(request).execute()
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = response.body?.string() ?: throw SodaException("결제정보에 오류가 있습니다.")
|
val body = response.body?.string()
|
||||||
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
val verifyResponse = objectMapper.readValue(body, PayverseVerifyResponse::class.java)
|
||||||
|
|
||||||
val customerId = "${serverEnv}_user_${member.id!!}"
|
val customerId = "${serverEnv}_user_${member.id!!}"
|
||||||
@@ -380,10 +385,10 @@ class ChargeService(
|
|||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +402,7 @@ class ChargeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,7 +410,7 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
fun charge(member: Member, request: ChargeRequest): ChargeResponse {
|
||||||
val can = canRepository.findByIdOrNull(request.canId)
|
val can = canRepository.findByIdOrNull(request.canId)
|
||||||
?: throw SodaException("잘못된 요청입니다\n앱 종료 후 다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "can.charge.invalid_request_restart")
|
||||||
|
|
||||||
val charge = Charge(can.can, can.rewardCan)
|
val charge = Charge(can.can, can.rewardCan)
|
||||||
charge.title = can.title
|
charge.title = can.title
|
||||||
@@ -424,9 +429,9 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||||
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
||||||
@@ -457,22 +462,22 @@ class ChargeService(
|
|||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||||
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
|
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
|
||||||
@@ -507,13 +512,13 @@ class ChargeService(
|
|||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,15 +547,17 @@ class ChargeService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
|
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.chargeId)
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
|
||||||
// 검증로직
|
// 검증로직
|
||||||
if (requestRealServerVerify(verifyRequest)) {
|
if (requestRealServerVerify(verifyRequest)) {
|
||||||
charge.payment?.receiptId = verifyRequest.receiptString
|
charge.payment?.receiptId = verifyRequest.receiptString
|
||||||
charge.payment?.method = "애플(인 앱 결제)"
|
charge.payment?.method = messageSource
|
||||||
|
.getMessage("can.charge.payment_method.apple_iap", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "ios")
|
member.charge(charge.chargeCan, charge.rewardCan, "ios")
|
||||||
|
|
||||||
@@ -567,10 +574,10 @@ class ChargeService(
|
|||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +601,9 @@ class ChargeService(
|
|||||||
payment.locale = currencyCode
|
payment.locale = currencyCode
|
||||||
payment.price = price
|
payment.price = price
|
||||||
payment.receiptId = purchaseToken
|
payment.receiptId = purchaseToken
|
||||||
payment.method = "구글(인 앱 결제)"
|
payment.method = messageSource
|
||||||
|
.getMessage("can.charge.payment_method.google_iap", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -610,9 +619,9 @@ class ChargeService(
|
|||||||
purchaseToken: String
|
purchaseToken: String
|
||||||
): ChargeCompleteResponse {
|
): ChargeCompleteResponse {
|
||||||
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
val charge = chargeRepository.findByIdOrNull(id = chargeId)
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.status == PaymentStatus.REQUEST) {
|
if (charge.payment!!.status == PaymentStatus.REQUEST) {
|
||||||
val orderId = verifyPurchase(purchaseToken, productId)
|
val orderId = verifyPurchase(purchaseToken, productId)
|
||||||
@@ -634,10 +643,10 @@ class ChargeService(
|
|||||||
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
isFirstCharged = chargeRepository.isFirstCharged(memberId)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("구매를 하지 못했습니다.\n고객센터로 문의해 주세요")
|
throw SodaException(messageKey = "can.charge.purchase_failed_contact")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,14 +679,14 @@ class ChargeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException(messageKey = "can.charge.payment_incomplete")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException(messageKey = "can.charge.payment_incomplete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,23 +710,31 @@ class ChargeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException(messageKey = "can.charge.payment_incomplete")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제를 완료하지 못했습니다.")
|
throw SodaException(messageKey = "can.charge.payment_incomplete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(key: String, vararg args: Any): String {
|
||||||
|
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
|
||||||
|
return String.format(template, *args)
|
||||||
|
}
|
||||||
|
|
||||||
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
// Payverse 결제수단 매핑: 특정 schemeCode는 "카드"로 표기, 아니면 null 반환
|
||||||
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
private fun mapPayverseSchemeToMethodByCode(schemeCode: String?): String? {
|
||||||
val cardCodes = setOf(
|
val cardCodes = setOf(
|
||||||
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
|
"041", "044", "361", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "381",
|
||||||
"218", "071", "002", "089", "045", "050", "048", "090", "092"
|
"218", "071", "002", "089", "045", "050", "048", "090", "092"
|
||||||
)
|
)
|
||||||
return if (schemeCode != null && cardCodes.contains(schemeCode)) "카드" else null
|
if (schemeCode == null || !cardCodes.contains(schemeCode)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return messageSource.getMessage("can.charge.payment_method.card", langContext.lang)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
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 kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||||
@@ -26,15 +28,17 @@ class ChargeEventService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val chargeEventRepository: ChargeEventRepository,
|
private val chargeEventRepository: ChargeEventRepository,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun applyChargeEvent(chargeId: Long, memberId: Long) {
|
fun applyChargeEvent(chargeId: Long, memberId: Long) {
|
||||||
val charge = chargeRepository.findByIdOrNull(chargeId)
|
val charge = chargeRepository.findByIdOrNull(chargeId)
|
||||||
?: throw SodaException("이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.")
|
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||||
|
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("이벤트가 적용되지 않았습니다.\n고객센터에 문의해 주세요.")
|
?: throw SodaException(messageKey = "can.charge.event.not_applied_contact")
|
||||||
|
|
||||||
if (member.auth != null) {
|
if (member.auth != null) {
|
||||||
val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di)
|
val authDate = authRepository.getOldestCreatedAtByDi(member.auth!!.di)
|
||||||
@@ -79,7 +83,10 @@ class ChargeEventService(
|
|||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
title = chargeEvent.title,
|
title = chargeEvent.title,
|
||||||
message = "$additionalCan 캔이 추가 지급되었습니다.",
|
message = formatMessage(
|
||||||
|
"can.charge.event.additional_can_paid",
|
||||||
|
additionalCan
|
||||||
|
),
|
||||||
recipients = listOf(member.id!!),
|
recipients = listOf(member.id!!),
|
||||||
isAuth = null
|
isAuth = null
|
||||||
)
|
)
|
||||||
@@ -94,14 +101,21 @@ class ChargeEventService(
|
|||||||
additionalCan = additionalCan,
|
additionalCan = additionalCan,
|
||||||
member = member,
|
member = member,
|
||||||
paymentGateway = charge.payment?.paymentGateway!!,
|
paymentGateway = charge.payment?.paymentGateway!!,
|
||||||
method = "첫 충전 이벤트"
|
method = messageSource
|
||||||
|
.getMessage("can.charge.event.first_title", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
)
|
)
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
title = "첫 충전 이벤트",
|
title = messageSource
|
||||||
message = "$additionalCan 캔이 추가 지급되었습니다.",
|
.getMessage("can.charge.event.first_title", langContext.lang)
|
||||||
|
.orEmpty(),
|
||||||
|
message = formatMessage(
|
||||||
|
"can.charge.event.additional_can_paid",
|
||||||
|
additionalCan
|
||||||
|
),
|
||||||
recipients = listOf(member.id!!),
|
recipients = listOf(member.id!!),
|
||||||
isAuth = null
|
isAuth = null
|
||||||
)
|
)
|
||||||
@@ -110,7 +124,7 @@ class ChargeEventService(
|
|||||||
|
|
||||||
private fun applyEvent(additionalCan: Int, member: Member, paymentGateway: PaymentGateway, method: String) {
|
private fun applyEvent(additionalCan: Int, member: Member, paymentGateway: PaymentGateway, method: String) {
|
||||||
val eventCharge = Charge(0, additionalCan, status = ChargeStatus.EVENT)
|
val eventCharge = Charge(0, additionalCan, status = ChargeStatus.EVENT)
|
||||||
eventCharge.title = "$additionalCan 캔"
|
eventCharge.title = formatMessage("can.charge.title", additionalCan)
|
||||||
eventCharge.member = member
|
eventCharge.member = member
|
||||||
|
|
||||||
val payment = Payment(
|
val payment = Payment(
|
||||||
@@ -127,4 +141,9 @@ class ChargeEventService(
|
|||||||
else -> member.charge(0, additionalCan, "pg")
|
else -> member.charge(0, additionalCan, "pg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(key: String, vararg args: Any): String {
|
||||||
|
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
|
||||||
|
return String.format(template, *args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class ChargeTempController(private val service: ChargeTempService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) {
|
if (member == null) {
|
||||||
throw SodaException("로그인 정보를 확인해주세요.")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(service.charge(member, request))
|
ApiResponse.ok(service.charge(member, request))
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
|||||||
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
|
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 kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
@@ -27,6 +29,8 @@ class ChargeTempService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
|
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${bootpay.hecto-application-id}")
|
@Value("\${bootpay.hecto-application-id}")
|
||||||
private val bootpayApplicationId: String,
|
private val bootpayApplicationId: String,
|
||||||
@@ -37,7 +41,7 @@ class ChargeTempService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun charge(member: Member, request: ChargeTempRequest): ChargeResponse {
|
fun charge(member: Member, request: ChargeTempRequest): ChargeResponse {
|
||||||
val charge = Charge(request.can, 0)
|
val charge = Charge(request.can, 0)
|
||||||
charge.title = "${request.can.moneyFormat()} 캔"
|
charge.title = formatMessage("can.charge.title", request.can.moneyFormat())
|
||||||
charge.member = member
|
charge.member = member
|
||||||
|
|
||||||
val payment = Payment(paymentGateway = request.paymentGateway)
|
val payment = Payment(paymentGateway = request.paymentGateway)
|
||||||
@@ -52,9 +56,9 @@ class ChargeTempService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun verify(user: User, verifyRequest: VerifyRequest) {
|
fun verify(user: User, verifyRequest: VerifyRequest) {
|
||||||
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
val charge = chargeRepository.findByIdOrNull(verifyRequest.orderId.toLong())
|
||||||
?: throw SodaException("결제정보에 오류가 있습니다.")
|
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
val member = memberRepository.findByEmail(user.username)
|
val member = memberRepository.findByEmail(user.username)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
|
||||||
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
|
||||||
@@ -72,13 +76,18 @@ class ChargeTempService(
|
|||||||
charge.payment?.status = PaymentStatus.COMPLETE
|
charge.payment?.status = PaymentStatus.COMPLETE
|
||||||
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
member.charge(charge.chargeCan, charge.rewardCan, "pg")
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("결제정보에 오류가 있습니다.")
|
throw SodaException(messageKey = "can.charge.invalid_payment_info")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(key: String, vararg args: Any): String {
|
||||||
|
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
|
||||||
|
return String.format(template, *args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.can.coupon
|
|||||||
|
|
||||||
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.core.io.InputStreamResource
|
import org.springframework.core.io.InputStreamResource
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
@@ -22,14 +24,18 @@ import java.nio.charset.StandardCharsets
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/can/coupon")
|
@RequestMapping("/can/coupon")
|
||||||
class CanCouponController(private val service: CanCouponService) {
|
class CanCouponController(
|
||||||
|
private val service: CanCouponService,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext
|
||||||
|
) {
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun generateCoupon(
|
fun generateCoupon(
|
||||||
@RequestBody request: GenerateCanCouponRequest,
|
@RequestBody request: GenerateCanCouponRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.generateCoupon(request))
|
ApiResponse.ok(service.generateCoupon(request))
|
||||||
}
|
}
|
||||||
@@ -40,7 +46,7 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
@RequestBody request: ModifyCanCouponRequest,
|
@RequestBody request: ModifyCanCouponRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.modifyCoupon(request))
|
ApiResponse.ok(service.modifyCoupon(request))
|
||||||
}
|
}
|
||||||
@@ -51,7 +57,7 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
ApiResponse.ok(service.getCouponList(offset = pageable.offset, limit = pageable.pageSize.toLong()))
|
ApiResponse.ok(service.getCouponList(offset = pageable.offset, limit = pageable.pageSize.toLong()))
|
||||||
}
|
}
|
||||||
@@ -63,7 +69,7 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getCouponNumberList(
|
service.getCouponNumberList(
|
||||||
@@ -80,9 +86,11 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
@RequestParam couponId: Long,
|
@RequestParam couponId: 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")
|
||||||
|
|
||||||
val fileName = "쿠폰번호리스트.xlsx"
|
val fileName = messageSource
|
||||||
|
.getMessage("can.coupon.download_filename", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
val encodedFileName = URLEncoder.encode(
|
val encodedFileName = URLEncoder.encode(
|
||||||
fileName,
|
fileName,
|
||||||
StandardCharsets.UTF_8.toString()
|
StandardCharsets.UTF_8.toString()
|
||||||
@@ -107,7 +115,7 @@ class CanCouponController(private val service: CanCouponService) {
|
|||||||
@RequestBody request: UseCanCouponRequest,
|
@RequestBody request: UseCanCouponRequest,
|
||||||
@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")
|
||||||
|
|
||||||
val completeMessage = service.useCanCoupon(
|
val completeMessage = service.useCanCoupon(
|
||||||
couponNumber = request.couponNumber,
|
couponNumber = request.couponNumber,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR
|
|||||||
if (!isMultipleUse(canCouponNumber)) {
|
if (!isMultipleUse(canCouponNumber)) {
|
||||||
val canCouponNumberList = couponNumberRepository.findByMemberId(memberId = memberId)
|
val canCouponNumberList = couponNumberRepository.findByMemberId(memberId = memberId)
|
||||||
if (canCouponNumberList.isNotEmpty()) {
|
if (canCouponNumberList.isNotEmpty()) {
|
||||||
throw SodaException("해당 쿠폰은 1회만 충전이 가능합니다.")
|
throw SodaException(messageKey = "can.coupon.single_use_only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR
|
|||||||
|
|
||||||
private fun checkCanCouponNumber(couponNumber: String): CanCouponNumber {
|
private fun checkCanCouponNumber(couponNumber: String): CanCouponNumber {
|
||||||
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
val canCouponNumber = couponNumberRepository.findByCouponNumber(couponNumber = couponNumber)
|
||||||
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
?: throw SodaException(messageKey = "can.coupon.invalid_number_contact")
|
||||||
|
|
||||||
if (canCouponNumber.member != null) {
|
if (canCouponNumber.member != null) {
|
||||||
throw SodaException("이미 사용한 쿠폰번호 입니다.")
|
throw SodaException(messageKey = "can.coupon.already_used")
|
||||||
}
|
}
|
||||||
|
|
||||||
return canCouponNumber
|
return canCouponNumber
|
||||||
@@ -34,17 +34,17 @@ class CanCouponIssueService(private val couponNumberRepository: CanCouponNumberR
|
|||||||
|
|
||||||
private fun validateCoupon(canCoupon: CanCoupon) {
|
private fun validateCoupon(canCoupon: CanCoupon) {
|
||||||
if (canCoupon.validity < LocalDateTime.now()) {
|
if (canCoupon.validity < LocalDateTime.now()) {
|
||||||
throw SodaException("유효기간이 경과된 쿠폰입니다.")
|
throw SodaException(messageKey = "can.coupon.expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canCoupon.isActive) {
|
if (!canCoupon.isActive) {
|
||||||
throw SodaException("이용이 불가능한 쿠폰입니다.")
|
throw SodaException(messageKey = "can.coupon.inactive")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkAnyChanges(request: ModifyCanCouponRequest) {
|
fun checkAnyChanges(request: ModifyCanCouponRequest) {
|
||||||
if (request.isMultipleUse == null && request.isActive == null && request.validity == null) {
|
if (request.isMultipleUse == null && request.isActive == null && request.validity == null) {
|
||||||
throw SodaException("변경사항이 없습니다.")
|
throw SodaException(messageKey = "can.coupon.no_changes")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.aws.sqs.SqsEvent
|
|||||||
import kr.co.vividnext.sodalive.aws.sqs.SqsEventType
|
import kr.co.vividnext.sodalive.aws.sqs.SqsEventType
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeService
|
import kr.co.vividnext.sodalive.can.charge.ChargeService
|
||||||
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.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -29,7 +31,9 @@ class CanCouponService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
|
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
fun generateCoupon(request: GenerateCanCouponRequest) {
|
fun generateCoupon(request: GenerateCanCouponRequest) {
|
||||||
val message = objectMapper.writeValueAsString(request)
|
val message = objectMapper.writeValueAsString(request)
|
||||||
@@ -41,7 +45,7 @@ class CanCouponService(
|
|||||||
issueService.checkAnyChanges(request)
|
issueService.checkAnyChanges(request)
|
||||||
|
|
||||||
val canCoupon = repository.findByIdOrNull(id = request.couponId)
|
val canCoupon = repository.findByIdOrNull(id = request.couponId)
|
||||||
?: throw SodaException("잘못된 쿠폰번호입니다.\n고객센터로 문의해 주시기 바랍니다.")
|
?: throw SodaException(messageKey = "can.coupon.invalid_number_contact")
|
||||||
|
|
||||||
if (request.validity != null) {
|
if (request.validity != null) {
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -51,7 +55,7 @@ class CanCouponService(
|
|||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (validity <= canCoupon.validity) {
|
if (validity <= canCoupon.validity) {
|
||||||
throw SodaException("유효기간은 기존 유효기간 이후 날짜로 설정하실 수 있습니다.")
|
throw SodaException(messageKey = "can.coupon.validity_after_current")
|
||||||
}
|
}
|
||||||
|
|
||||||
canCoupon.validity = validity
|
canCoupon.validity = validity
|
||||||
@@ -85,7 +89,11 @@ class CanCouponService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun downloadCouponNumberList(couponId: Long): ByteArrayInputStream {
|
fun downloadCouponNumberList(couponId: Long): ByteArrayInputStream {
|
||||||
val header = listOf("순번", "쿠폰번호", "사용여부")
|
val header = listOf(
|
||||||
|
messageSource.getMessage("can.coupon.download_header.index", langContext.lang).orEmpty(),
|
||||||
|
messageSource.getMessage("can.coupon.download_header.number", langContext.lang).orEmpty(),
|
||||||
|
messageSource.getMessage("can.coupon.download_header.used", langContext.lang).orEmpty()
|
||||||
|
)
|
||||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
val byteArrayOutputStream = ByteArrayOutputStream()
|
||||||
val couponNumberList = couponNumberRepository.getAllCouponNumberList(couponId)
|
val couponNumberList = couponNumberRepository.getAllCouponNumberList(couponId)
|
||||||
|
|
||||||
@@ -104,9 +112,9 @@ class CanCouponService(
|
|||||||
couponNumberRow.createCell(1).setCellValue(insertHyphens(item.couponNumber))
|
couponNumberRow.createCell(1).setCellValue(insertHyphens(item.couponNumber))
|
||||||
couponNumberRow.createCell(2).setCellValue(
|
couponNumberRow.createCell(2).setCellValue(
|
||||||
if (item.isUsed) {
|
if (item.isUsed) {
|
||||||
"O"
|
messageSource.getMessage("can.coupon.download_used_mark", langContext.lang).orEmpty()
|
||||||
} else {
|
} else {
|
||||||
"X"
|
messageSource.getMessage("can.coupon.download_unused_mark", langContext.lang).orEmpty()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -114,7 +122,7 @@ class CanCouponService(
|
|||||||
workbook.write(byteArrayOutputStream)
|
workbook.write(byteArrayOutputStream)
|
||||||
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
|
return ByteArrayInputStream(byteArrayOutputStream.toByteArray())
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
throw SodaException("다운로드를 하지 못했습니다.\n다시 시도해 주세요.")
|
throw SodaException(messageKey = "can.coupon.download_failed_retry")
|
||||||
} finally {
|
} finally {
|
||||||
workbook.close()
|
workbook.close()
|
||||||
byteArrayOutputStream.close()
|
byteArrayOutputStream.close()
|
||||||
@@ -123,9 +131,9 @@ class CanCouponService(
|
|||||||
|
|
||||||
fun useCanCoupon(couponNumber: String, memberId: Long): String {
|
fun useCanCoupon(couponNumber: String, memberId: Long): String {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("로그인 정보를 확인해주세요.")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (member.auth == null) throw SodaException("쿠폰은 본인인증을 하셔야 사용이 가능합니다.")
|
if (member.auth == null) throw SodaException(messageKey = "can.coupon.auth_required")
|
||||||
|
|
||||||
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
issueService.validateAvailableUseCoupon(couponNumber, memberId)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.common.SodaException
|
|||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.order.Order
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
@@ -31,7 +33,9 @@ class CanPaymentService(
|
|||||||
private val memberRepository: MemberRepository,
|
private val memberRepository: MemberRepository,
|
||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val useCanRepository: UseCanRepository,
|
private val useCanRepository: UseCanRepository,
|
||||||
private val useCanCalculateRepository: UseCanCalculateRepository
|
private val useCanCalculateRepository: UseCanCalculateRepository,
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun spendCan(
|
fun spendCan(
|
||||||
@@ -49,7 +53,7 @@ class CanPaymentService(
|
|||||||
container: String
|
container: String
|
||||||
) {
|
) {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container)
|
||||||
@@ -58,14 +62,14 @@ class CanPaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0)
|
||||||
throw SodaException(
|
throw SodaException(
|
||||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
formatMessage("can.payment.insufficient_can", shortCan)
|
||||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useCan = UseCan(
|
val useCan = UseCan(
|
||||||
@@ -121,7 +125,7 @@ class CanPaymentService(
|
|||||||
useCan.chatRoomId = chatRoomId
|
useCan.chatRoomId = chatRoomId
|
||||||
useCan.characterId = characterId
|
useCan.characterId = characterId
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("잘못된 요청입니다.")
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
}
|
}
|
||||||
|
|
||||||
useCanRepository.save(useCan)
|
useCanRepository.save(useCan)
|
||||||
@@ -306,20 +310,20 @@ class CanPaymentService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun refund(memberId: Long, roomId: Long) {
|
fun refund(memberId: Long, roomId: Long) {
|
||||||
val member = memberRepository.findByIdOrNull(memberId)
|
val member = memberRepository.findByIdOrNull(memberId)
|
||||||
?: throw SodaException("잘못된 예약정보 입니다.")
|
?: throw SodaException(messageKey = "can.payment.invalid_reservation")
|
||||||
|
|
||||||
val useCan = repository.getCanUsedForLiveRoomNotRefund(
|
val useCan = repository.getCanUsedForLiveRoomNotRefund(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
canUsage = CanUsage.LIVE
|
canUsage = CanUsage.LIVE
|
||||||
) ?: throw SodaException("잘못된 예약정보 입니다.")
|
) ?: throw SodaException(messageKey = "can.payment.invalid_reservation")
|
||||||
useCan.isRefund = true
|
useCan.isRefund = true
|
||||||
|
|
||||||
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
|
val useCanCalculates = useCanCalculateRepository.findByUseCanIdAndStatus(useCan.id!!)
|
||||||
useCanCalculates.forEach {
|
useCanCalculates.forEach {
|
||||||
it.status = UseCanCalculateStatus.REFUND
|
it.status = UseCanCalculateStatus.REFUND
|
||||||
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
||||||
charge.title = "${it.can} 캔"
|
charge.title = formatMessage("can.charge.title", it.can)
|
||||||
charge.useCan = useCan
|
charge.useCan = useCan
|
||||||
|
|
||||||
when (it.paymentGateway) {
|
when (it.paymentGateway) {
|
||||||
@@ -333,7 +337,9 @@ class CanPaymentService(
|
|||||||
status = PaymentStatus.COMPLETE,
|
status = PaymentStatus.COMPLETE,
|
||||||
paymentGateway = it.paymentGateway
|
paymentGateway = it.paymentGateway
|
||||||
)
|
)
|
||||||
payment.method = "환불"
|
payment.method = messageSource
|
||||||
|
.getMessage("can.payment.method.refund", langContext.lang)
|
||||||
|
.orEmpty()
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -348,7 +354,7 @@ class CanPaymentService(
|
|||||||
container: String
|
container: String
|
||||||
) {
|
) {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
|
|
||||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
@@ -358,14 +364,14 @@ class CanPaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0)
|
||||||
throw SodaException(
|
throw SodaException(
|
||||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
formatMessage("can.payment.insufficient_can", shortCan)
|
||||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useCan = UseCan(
|
val useCan = UseCan(
|
||||||
@@ -394,7 +400,7 @@ class CanPaymentService(
|
|||||||
container: String
|
container: String
|
||||||
) {
|
) {
|
||||||
val member = memberRepository.findByIdOrNull(id = memberId)
|
val member = memberRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
|
|
||||||
val useRewardCan = spendRewardCan(member, needCan, container)
|
val useRewardCan = spendRewardCan(member, needCan, container)
|
||||||
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
val useChargeCan = if (needCan - useRewardCan.total > 0) {
|
||||||
@@ -404,14 +410,14 @@ class CanPaymentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) {
|
||||||
|
val shortCan = needCan - useRewardCan.total - (useChargeCan?.total ?: 0)
|
||||||
throw SodaException(
|
throw SodaException(
|
||||||
"${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " +
|
formatMessage("can.payment.insufficient_can", shortCan)
|
||||||
"캔이 부족합니다. 충전 후 이용해 주세요."
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
if (!useRewardCan.verify() || useChargeCan?.verify() == false) {
|
||||||
throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
throw SodaException(messageKey = "can.payment.invalid_request_retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
val useCan = UseCan(
|
val useCan = UseCan(
|
||||||
@@ -435,4 +441,9 @@ class CanPaymentService(
|
|||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP)
|
||||||
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(key: String, vararg args: Any): String {
|
||||||
|
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
|
||||||
|
return String.format(template, *args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class ChatCharacter(
|
|||||||
// 캐릭터 한 줄 소개
|
// 캐릭터 한 줄 소개
|
||||||
var description: String,
|
var description: String,
|
||||||
|
|
||||||
|
var languageCode: String? = null,
|
||||||
|
|
||||||
// AI 시스템 프롬프트
|
// AI 시스템 프롬프트
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var systemPrompt: String,
|
var systemPrompt: String,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
|||||||
data class CharacterComment(
|
data class CharacterComment(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var comment: String,
|
var comment: String,
|
||||||
|
var languageCode: String?,
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -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,11 +47,11 @@ 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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
// Request DTOs
|
// Request DTOs
|
||||||
data class CreateCharacterCommentRequest(
|
data class CreateCharacterCommentRequest(
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
@@ -20,7 +21,8 @@ data class CharacterCommentResponse(
|
|||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val replyCount: Int,
|
val replyCount: Int,
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
// 답글 Response 단건(목록 원소)
|
// 답글 Response 단건(목록 원소)
|
||||||
@@ -35,7 +37,8 @@ data class CharacterReplyResponse(
|
|||||||
val memberProfileImage: String,
|
val memberProfileImage: String,
|
||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
// 댓글의 답글 조회 Response 컨테이너
|
// 댓글의 답글 조회 Response 컨테이너
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -12,7 +15,8 @@ import java.time.ZoneId
|
|||||||
class CharacterCommentService(
|
class CharacterCommentService(
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
private val commentRepository: CharacterCommentRepository,
|
private val commentRepository: CharacterCommentRepository,
|
||||||
private val reportRepository: CharacterCommentReportRepository
|
private val reportRepository: CharacterCommentReportRepository,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||||
@@ -32,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!!,
|
||||||
@@ -40,49 +44,84 @@ class CharacterCommentService(
|
|||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||||
comment = entity.comment
|
comment = entity.comment,
|
||||||
|
languageCode = entity.languageCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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!!,
|
||||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
comment = entity.comment
|
comment = entity.comment,
|
||||||
|
languageCode = entity.languageCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addComment(characterId: Long, member: Member, text: String): Long {
|
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
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)
|
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = entity.id!!,
|
||||||
|
query = text,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
fun addReply(
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
characterId: Long,
|
||||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
parentCommentId: Long,
|
||||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
member: Member,
|
||||||
if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.")
|
text: String,
|
||||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
languageCode: String? = null
|
||||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
): Long {
|
||||||
|
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)
|
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
entity.parent = parent
|
entity.parent = parent
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = entity.id!!,
|
||||||
|
query = text,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,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) {
|
||||||
@@ -171,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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.controller
|
package kr.co.vividnext.sodalive.chat.character.controller
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||||
@@ -10,11 +11,21 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
|
|||||||
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||||
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.translation.PapagoTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||||
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.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
@@ -32,7 +43,12 @@ class ChatCharacterController(
|
|||||||
private val bannerService: ChatCharacterBannerService,
|
private val bannerService: ChatCharacterBannerService,
|
||||||
private val chatRoomService: ChatRoomService,
|
private val chatRoomService: ChatRoomService,
|
||||||
private val characterCommentService: CharacterCommentService,
|
private val characterCommentService: CharacterCommentService,
|
||||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
private val curationQueryService: CharacterCurationQueryService,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
@@ -65,6 +81,24 @@ class ChatCharacterController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val characterIds = recentCharacters.map { it.characterId }
|
||||||
|
val translatedRecentCharacters = if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
recentCharacters.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
if (translatedName.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recentCharacters
|
||||||
|
}
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
|
|
||||||
@@ -103,10 +137,10 @@ class ChatCharacterController(
|
|||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
CharacterMainResponse(
|
CharacterMainResponse(
|
||||||
banners = banners,
|
banners = banners,
|
||||||
recentCharacters = recentCharacters,
|
recentCharacters = translatedRecentCharacters,
|
||||||
popularCharacters = popularCharacters,
|
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
|
||||||
newCharacters = newCharacters,
|
newCharacters = getTranslatedAiCharacterList(newCharacters),
|
||||||
recommendCharacters = recommendCharacters,
|
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
|
||||||
curationSections = curationSections
|
curationSections = curationSections
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -121,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
|
||||||
@@ -148,6 +182,118 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var translated: TranslatedAiCharacterDetail? = null
|
||||||
|
if (langContext.lang.code != character.languageCode) {
|
||||||
|
val existing = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdAndLocale(character.id!!, langContext.lang.code)
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
val payload = existing.renderedPayload
|
||||||
|
translated = TranslatedAiCharacterDetail(
|
||||||
|
name = payload.name,
|
||||||
|
description = payload.description,
|
||||||
|
gender = payload.gender,
|
||||||
|
personality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = payload.personalityTrait,
|
||||||
|
description = payload.personalityDescription
|
||||||
|
).takeIf {
|
||||||
|
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||||
|
},
|
||||||
|
background = TranslatedAiCharacterBackground(
|
||||||
|
topic = payload.backgroundTopic,
|
||||||
|
description = payload.backgroundDescription
|
||||||
|
).takeIf {
|
||||||
|
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||||
|
},
|
||||||
|
tags = payload.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(character.name)
|
||||||
|
texts.add(character.description)
|
||||||
|
texts.add(character.gender ?: "")
|
||||||
|
|
||||||
|
val hasPersonality = personality != null
|
||||||
|
if (hasPersonality) {
|
||||||
|
texts.add(personality!!.trait)
|
||||||
|
texts.add(personality.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBackground = background != null
|
||||||
|
if (hasBackground) {
|
||||||
|
texts.add(background!!.topic)
|
||||||
|
texts.add(background.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = character.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = langContext.lang.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedName = translatedTexts[index++]
|
||||||
|
val translatedDescription = translatedTexts[index++]
|
||||||
|
val translatedGender = translatedTexts[index++]
|
||||||
|
|
||||||
|
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||||
|
if (hasPersonality) {
|
||||||
|
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||||
|
if (hasBackground) {
|
||||||
|
translatedBackground = TranslatedAiCharacterBackground(
|
||||||
|
topic = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = AiCharacterTranslationRenderedPayload(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personalityTrait = translatedPersonality?.trait ?: "",
|
||||||
|
personalityDescription = translatedPersonality?.description ?: "",
|
||||||
|
backgroundTopic = translatedBackground?.topic ?: "",
|
||||||
|
backgroundDescription = translatedBackground?.description ?: "",
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val entity = AiCharacterTranslation(
|
||||||
|
characterId = character.id!!,
|
||||||
|
locale = langContext.lang.code,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
|
||||||
|
aiCharacterTranslationRepository.save(entity)
|
||||||
|
|
||||||
|
translated = TranslatedAiCharacterDetail(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personality = translatedPersonality,
|
||||||
|
background = translatedBackground,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||||
.map { other ->
|
.map { other ->
|
||||||
@@ -162,6 +308,35 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 캐릭터 이름, 태그 번역 데이터 조회
|
||||||
|
*
|
||||||
|
* languageCode != null
|
||||||
|
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
|
||||||
|
*
|
||||||
|
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
|
||||||
|
*/
|
||||||
|
val characterIds = others.map { it.characterId }
|
||||||
|
val translatedOthers = if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
others.map { other ->
|
||||||
|
val payload = translations[other.characterId]?.renderedPayload
|
||||||
|
val translatedName = payload?.name
|
||||||
|
val translatedTags = payload?.tags
|
||||||
|
|
||||||
|
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
|
||||||
|
other
|
||||||
|
} else {
|
||||||
|
other.copy(name = translatedName, tags = translatedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
others
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 댓글 1개 조회
|
// 최신 댓글 1개 조회
|
||||||
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
||||||
|
|
||||||
@@ -171,6 +346,7 @@ class ChatCharacterController(
|
|||||||
characterId = character.id!!,
|
characterId = character.id!!,
|
||||||
name = character.name,
|
name = character.name,
|
||||||
description = character.description,
|
description = character.description,
|
||||||
|
languageCode = character.languageCode,
|
||||||
mbti = character.mbti,
|
mbti = character.mbti,
|
||||||
gender = character.gender,
|
gender = character.gender,
|
||||||
age = character.age,
|
age = character.age,
|
||||||
@@ -181,9 +357,10 @@ class ChatCharacterController(
|
|||||||
originalTitle = character.originalTitle,
|
originalTitle = character.originalTitle,
|
||||||
originalLink = character.originalLink,
|
originalLink = character.originalLink,
|
||||||
characterType = character.characterType,
|
characterType = character.characterType,
|
||||||
others = others,
|
others = translatedOthers,
|
||||||
latestComment = latestComment,
|
latestComment = latestComment,
|
||||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
||||||
|
translated = translated
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,13 +371,20 @@ class ChatCharacterController(
|
|||||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||||
*/
|
*/
|
||||||
@GetMapping("/recent")
|
@GetMapping("/recent")
|
||||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
fun getRecentCharacters(
|
||||||
ApiResponse.ok(
|
@RequestParam("page", required = false) page: Int?
|
||||||
service.getRecentCharactersPage(
|
): ApiResponse<RecentCharactersResponse> = run {
|
||||||
page = page ?: 0,
|
val characterPage = service.getRecentCharactersPage(
|
||||||
size = 20
|
page = page ?: 0,
|
||||||
)
|
size = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val translatedCharacterPage = RecentCharactersResponse(
|
||||||
|
totalCount = characterPage.totalCount,
|
||||||
|
content = getTranslatedAiCharacterList(characterPage.content)
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(translatedCharacterPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,6 +403,48 @@ class ChatCharacterController(
|
|||||||
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
||||||
.map { it.characterId }
|
.map { it.characterId }
|
||||||
}
|
}
|
||||||
ApiResponse.ok(service.getRecommendCharacters(recent, 20))
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
getTranslatedAiCharacterList(
|
||||||
|
service.getRecommendCharacters(
|
||||||
|
recent,
|
||||||
|
20
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||||
|
* 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||||
|
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||||
|
*
|
||||||
|
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||||
|
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||||
|
val characterIds = aiCharacterList.map { it.characterId }
|
||||||
|
|
||||||
|
return if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
aiCharacterList.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||||
|
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName, description = translatedDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
aiCharacterList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package kr.co.vividnext.sodalive.chat.character.dto
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||||
|
|
||||||
data class CharacterDetailResponse(
|
data class CharacterDetailResponse(
|
||||||
val characterId: Long,
|
val characterId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val languageCode: String?,
|
||||||
val mbti: String?,
|
val mbti: String?,
|
||||||
val gender: String?,
|
val gender: String?,
|
||||||
val age: Int?,
|
val age: Int?,
|
||||||
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
|
|||||||
val characterType: CharacterType,
|
val characterType: CharacterType,
|
||||||
val others: List<OtherCharacter>,
|
val others: List<OtherCharacter>,
|
||||||
val latestComment: CharacterCommentResponse?,
|
val latestComment: CharacterCommentResponse?,
|
||||||
val totalComments: Int
|
val totalComments: Int,
|
||||||
|
val translated: TranslatedAiCharacterDetail?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OtherCharacter(
|
data class OtherCharacter(
|
||||||
|
|||||||
@@ -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!!,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.translate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.AttributeConverter
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Convert
|
||||||
|
import javax.persistence.Converter
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.UniqueConstraint
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
uniqueConstraints = [
|
||||||
|
UniqueConstraint(columnNames = ["characterId", "locale"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class AiCharacterTranslation(
|
||||||
|
val characterId: Long,
|
||||||
|
val locale: String,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
||||||
|
var renderedPayload: AiCharacterTranslationRenderedPayload
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
data class AiCharacterTranslationRenderedPayload(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val gender: String,
|
||||||
|
val personalityTrait: String,
|
||||||
|
val personalityDescription: String,
|
||||||
|
val backgroundTopic: String,
|
||||||
|
val backgroundDescription: String,
|
||||||
|
val tags: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class AiCharacterTranslationRenderedPayloadConverter :
|
||||||
|
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String {
|
||||||
|
if (attribute == null) return "{}"
|
||||||
|
return objectMapper.writeValueAsString(attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload {
|
||||||
|
if (dbData.isNullOrBlank()) {
|
||||||
|
return AiCharacterTranslationRenderedPayload(
|
||||||
|
name = "",
|
||||||
|
description = "",
|
||||||
|
gender = "",
|
||||||
|
personalityTrait = "",
|
||||||
|
personalityDescription = "",
|
||||||
|
backgroundTopic = "",
|
||||||
|
backgroundDescription = "",
|
||||||
|
tags = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(dbData)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterDetail(
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
val gender: String?,
|
||||||
|
val personality: TranslatedAiCharacterPersonality?,
|
||||||
|
val background: TranslatedAiCharacterBackground?,
|
||||||
|
val tags: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterPersonality(
|
||||||
|
val trait: String?,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterBackground(
|
||||||
|
val topic: String?,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.translate
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
|
||||||
|
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
|
||||||
|
|
||||||
|
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ class OriginalWork(
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
var description: String = "",
|
var description: String = "",
|
||||||
|
|
||||||
|
/** 언어 코드 */
|
||||||
|
@Column(nullable = true)
|
||||||
|
var languageCode: String? = null,
|
||||||
|
|
||||||
/** 원천 원작 */
|
/** 원천 원작 */
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var originalWork: String? = null,
|
var originalWork: String? = null,
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package kr.co.vividnext.sodalive.chat.original.controller
|
|||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse
|
||||||
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse
|
||||||
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||||
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.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
|
||||||
@@ -30,6 +34,12 @@ class OriginalWorkController(
|
|||||||
private val queryService: OriginalWorkQueryService,
|
private val queryService: OriginalWorkQueryService,
|
||||||
private val characterImageRepository: CharacterImageRepository,
|
private val characterImageRepository: CharacterImageRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
|
private val originalWorkTranslationService: OriginalWorkTranslationService,
|
||||||
|
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@@ -51,7 +61,57 @@ class OriginalWorkController(
|
|||||||
val includeAdult = member?.auth != null
|
val includeAdult = member?.auth != null
|
||||||
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
||||||
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
|
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
|
||||||
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
|
|
||||||
|
/**
|
||||||
|
* 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||||
|
* originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다.
|
||||||
|
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||||
|
*
|
||||||
|
* 성능:
|
||||||
|
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||||
|
*/
|
||||||
|
val translatedContent = run {
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
content
|
||||||
|
} else {
|
||||||
|
val ids = content.map { it.id }.toSet()
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val translations = originalWorkTranslationRepository
|
||||||
|
.findByOriginalWorkIdInAndLocale(ids, locale)
|
||||||
|
.associateBy { it.originalWorkId }
|
||||||
|
|
||||||
|
content.map { item ->
|
||||||
|
val payload = translations[item.id]?.renderedPayload
|
||||||
|
if (payload != null) {
|
||||||
|
val newTitle = payload.title.trim()
|
||||||
|
val newContentType = payload.contentType.trim()
|
||||||
|
val hasTitle = newTitle.isNotEmpty()
|
||||||
|
val hasContentType = newContentType.isNotEmpty()
|
||||||
|
if (hasTitle || hasContentType) {
|
||||||
|
item.copy(
|
||||||
|
title = if (hasTitle) newTitle else item.title,
|
||||||
|
contentType = if (hasContentType) newContentType else item.contentType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkListResponse(
|
||||||
|
totalCount = pageRes.totalElements,
|
||||||
|
content = translatedContent
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,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
|
||||||
@@ -83,20 +143,56 @@ class OriginalWorkController(
|
|||||||
emptySet()
|
emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(
|
val translatedOriginal = originalWorkTranslationService.ensureTranslated(
|
||||||
OriginalWorkDetailResponse.from(
|
originalWork = ow,
|
||||||
ow,
|
targetLocale = langContext.lang.code
|
||||||
imageHost,
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||||
|
* AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다.
|
||||||
|
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||||
|
*
|
||||||
|
* 성능:
|
||||||
|
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||||
|
*/
|
||||||
|
val translatedCharacters = run {
|
||||||
|
if (chars.isEmpty()) {
|
||||||
|
emptyList<Character>()
|
||||||
|
} else {
|
||||||
|
val ids = chars.mapNotNull { it.id }
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
chars.map<ChatCharacter, Character> {
|
chars.map<ChatCharacter, Character> {
|
||||||
val path = it.imagePath ?: "profile/default-profile.png"
|
val path = it.imagePath ?: "profile/default-profile.png"
|
||||||
|
val tr = translations[it.id!!]?.renderedPayload
|
||||||
|
val newName = tr?.name?.trim().orEmpty()
|
||||||
|
val newDesc = tr?.description?.trim().orEmpty()
|
||||||
|
val hasName = newName.isNotEmpty()
|
||||||
|
val hasDesc = newDesc.isNotEmpty()
|
||||||
Character(
|
Character(
|
||||||
characterId = it.id!!,
|
characterId = it.id!!,
|
||||||
name = it.name,
|
name = if (hasName) newName else it.name,
|
||||||
description = it.description,
|
description = if (hasDesc) newDesc else it.description,
|
||||||
imageUrl = "$imageHost/$path",
|
imageUrl = "$imageHost/$path",
|
||||||
new = recentSet.contains(it.id)
|
new = recentSet.contains(it.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
OriginalWorkDetailResponse.from(
|
||||||
|
ow,
|
||||||
|
imageHost,
|
||||||
|
translatedCharacters,
|
||||||
|
translated = translatedOriginal
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.original.dto
|
|||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 앱용 원작 목록 아이템 응답 DTO
|
* 앱용 원작 목록 아이템 응답 DTO
|
||||||
@@ -54,13 +55,15 @@ data class OriginalWorkDetailResponse(
|
|||||||
@JsonProperty("studio") val studio: String?,
|
@JsonProperty("studio") val studio: String?,
|
||||||
@JsonProperty("originalLinks") val originalLinks: List<String>,
|
@JsonProperty("originalLinks") val originalLinks: List<String>,
|
||||||
@JsonProperty("tags") val tags: List<String>,
|
@JsonProperty("tags") val tags: List<String>,
|
||||||
@JsonProperty("characters") val characters: List<Character>
|
@JsonProperty("characters") val characters: List<Character>,
|
||||||
|
@JsonProperty("translated") val translated: TranslatedOriginalWork?
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun from(
|
fun from(
|
||||||
entity: OriginalWork,
|
entity: OriginalWork,
|
||||||
imageHost: String = "",
|
imageHost: String = "",
|
||||||
characters: List<Character>
|
characters: List<Character>,
|
||||||
|
translated: TranslatedOriginalWork?
|
||||||
): OriginalWorkDetailResponse {
|
): OriginalWorkDetailResponse {
|
||||||
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||||
"$imageHost/${entity.imagePath}"
|
"$imageHost/${entity.imagePath}"
|
||||||
@@ -80,7 +83,8 @@ data class OriginalWorkDetailResponse(
|
|||||||
studio = entity.studio,
|
studio = entity.studio,
|
||||||
originalLinks = entity.originalLinks.map { it.url },
|
originalLinks = entity.originalLinks.map { it.url },
|
||||||
tags = entity.tagMappings.map { it.tag.tag },
|
tags = entity.tagMappings.map { it.tag.tag },
|
||||||
characters = characters
|
characters = characters,
|
||||||
|
translated = translated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.service
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class OriginalWorkTranslationService(
|
||||||
|
private val translationRepository: OriginalWorkTranslationRepository,
|
||||||
|
private val papagoTranslationService: PapagoTranslationService
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
|
||||||
|
* - 기존 번역이 있으면 그대로 사용
|
||||||
|
* - 없으면 파파고 번역 수행 후 저장
|
||||||
|
* - 실패/불필요 시 null 반환
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? {
|
||||||
|
val source = originalWork.languageCode?.lowercase()
|
||||||
|
val target = targetLocale.lowercase()
|
||||||
|
|
||||||
|
if (source.isNullOrBlank() || source == target) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 번역 조회
|
||||||
|
val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target)
|
||||||
|
val existedPayload = existed?.renderedPayload
|
||||||
|
if (existedPayload != null) {
|
||||||
|
val t = existedPayload.title.trim()
|
||||||
|
val ct = existedPayload.contentType.trim()
|
||||||
|
val cat = existedPayload.category.trim()
|
||||||
|
val desc = existedPayload.description.trim()
|
||||||
|
val tags = existedPayload.tags
|
||||||
|
val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty()
|
||||||
|
if (hasAny) {
|
||||||
|
return TranslatedOriginalWork(
|
||||||
|
title = t,
|
||||||
|
contentType = ct,
|
||||||
|
category = cat,
|
||||||
|
description = desc,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파파고 번역 수행
|
||||||
|
return try {
|
||||||
|
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
|
||||||
|
val texts = buildList {
|
||||||
|
add(originalWork.title)
|
||||||
|
add(originalWork.contentType)
|
||||||
|
add(originalWork.category)
|
||||||
|
add(originalWork.description)
|
||||||
|
addAll(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = papagoTranslationService.translate(
|
||||||
|
TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = source,
|
||||||
|
targetLanguage = target
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val out = response.translatedText
|
||||||
|
if (out.isEmpty()) return null
|
||||||
|
|
||||||
|
// 앞 4개는 필드, 나머지는 태그
|
||||||
|
val title = out.getOrNull(0)?.trim().orEmpty()
|
||||||
|
val contentType = out.getOrNull(1)?.trim().orEmpty()
|
||||||
|
val category = out.getOrNull(2)?.trim().orEmpty()
|
||||||
|
val description = out.getOrNull(3)?.trim().orEmpty()
|
||||||
|
val translatedTags = if (out.size > 4) {
|
||||||
|
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
|
||||||
|
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
|
||||||
|
if (!hasAny) return null
|
||||||
|
|
||||||
|
val payload = OriginalWorkTranslationPayload(
|
||||||
|
title = title,
|
||||||
|
contentType = contentType,
|
||||||
|
category = category,
|
||||||
|
description = description,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val entity = existed?.apply { this.renderedPayload = payload }
|
||||||
|
?: OriginalWorkTranslation(
|
||||||
|
originalWorkId = originalWork.id!!,
|
||||||
|
locale = target,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
|
||||||
|
translationRepository.save(entity)
|
||||||
|
|
||||||
|
TranslatedOriginalWork(
|
||||||
|
title = title,
|
||||||
|
contentType = contentType,
|
||||||
|
category = category,
|
||||||
|
description = description,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.translation
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.AttributeConverter
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Convert
|
||||||
|
import javax.persistence.Converter
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.UniqueConstraint
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
uniqueConstraints = [
|
||||||
|
UniqueConstraint(columnNames = ["original_work_id", "locale"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class OriginalWorkTranslation(
|
||||||
|
@Column(name = "original_work_id")
|
||||||
|
val originalWorkId: Long,
|
||||||
|
@Column(name = "locale")
|
||||||
|
val locale: String,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
@Convert(converter = OriginalWorkTranslationPayloadConverter::class)
|
||||||
|
var renderedPayload: OriginalWorkTranslationPayload
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
data class OriginalWorkTranslationPayload(
|
||||||
|
val title: String,
|
||||||
|
val contentType: String,
|
||||||
|
val category: String,
|
||||||
|
val description: String,
|
||||||
|
val tags: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslatedOriginalWork(
|
||||||
|
val title: String,
|
||||||
|
val contentType: String,
|
||||||
|
val category: String,
|
||||||
|
val description: String,
|
||||||
|
val tags: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String {
|
||||||
|
if (attribute == null) return "{}"
|
||||||
|
return objectMapper.writeValueAsString(attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload {
|
||||||
|
if (dbData.isNullOrBlank()) {
|
||||||
|
return OriginalWorkTranslationPayload(
|
||||||
|
title = "",
|
||||||
|
contentType = "",
|
||||||
|
category = "",
|
||||||
|
description = "",
|
||||||
|
tags = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val node = objectMapper.readTree(dbData)
|
||||||
|
val title = node.get("title")?.asText() ?: ""
|
||||||
|
val contentType = node.get("contentType")?.asText() ?: ""
|
||||||
|
val category = node.get("category")?.asText() ?: ""
|
||||||
|
val description = node.get("description")?.asText() ?: ""
|
||||||
|
val tagsNode = node.get("tags")
|
||||||
|
val tags: List<String> = when {
|
||||||
|
tagsNode == null || tagsNode.isNull -> emptyList()
|
||||||
|
tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
|
||||||
|
tagsNode.isTextual -> tagsNode.asText()
|
||||||
|
.split(',')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
OriginalWorkTranslationPayload(
|
||||||
|
title = title,
|
||||||
|
contentType = contentType,
|
||||||
|
category = category,
|
||||||
|
description = description,
|
||||||
|
tags = tags
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
OriginalWorkTranslationPayload(
|
||||||
|
title = "",
|
||||||
|
contentType = "",
|
||||||
|
category = "",
|
||||||
|
description = "",
|
||||||
|
tags = emptyList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.original.translation
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface OriginalWorkTranslationRepository : JpaRepository<OriginalWorkTranslation, Long> {
|
||||||
|
fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation?
|
||||||
|
|
||||||
|
fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set<Long>, locale: String): List<OriginalWorkTranslation>
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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!!)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 둘 다 가능 → 차감
|
// 둘 다 가능 → 차감
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
|
|||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService
|
import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
import kr.co.vividnext.sodalive.chat.room.ChatMessage
|
||||||
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
|
import kr.co.vividnext.sodalive.chat.room.ChatMessageType
|
||||||
@@ -26,6 +27,9 @@ import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository
|
|||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository
|
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.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
|
||||||
@@ -50,6 +54,9 @@ class ChatRoomService(
|
|||||||
private val messageRepository: ChatMessageRepository,
|
private val messageRepository: ChatMessageRepository,
|
||||||
private val characterService: ChatCharacterService,
|
private val characterService: ChatCharacterService,
|
||||||
private val characterImageService: CharacterImageService,
|
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 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,
|
||||||
private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService,
|
private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService,
|
||||||
@@ -72,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) {
|
||||||
@@ -119,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)
|
||||||
@@ -220,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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,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 {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
@@ -270,10 +277,22 @@ class ChatRoomService(
|
|||||||
val time = latest?.createdAt ?: q.lastActivityAt
|
val time = latest?.createdAt ?: q.lastActivityAt
|
||||||
val timeLabel = formatRelativeTime(time)
|
val timeLabel = formatRelativeTime(time)
|
||||||
|
|
||||||
|
// 언어 컨텍스트(en/ja)에서 번역본이 존재하면 번역된 캐릭터명을 사용
|
||||||
|
val localizedTitle = when (langContext.lang) {
|
||||||
|
Lang.EN, Lang.JA -> {
|
||||||
|
val tr = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdAndLocale(q.characterId, langContext.lang.code)
|
||||||
|
val name = tr?.renderedPayload?.name
|
||||||
|
if (!name.isNullOrBlank()) name else q.title
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> q.title
|
||||||
|
}
|
||||||
|
|
||||||
ChatRoomListItemDto(
|
ChatRoomListItemDto(
|
||||||
chatRoomId = q.chatRoomId,
|
chatRoomId = q.chatRoomId,
|
||||||
characterId = q.characterId,
|
characterId = q.characterId,
|
||||||
title = q.title,
|
title = localizedTitle,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
opponentType = opponentType,
|
opponentType = opponentType,
|
||||||
lastMessagePreview = preview,
|
lastMessagePreview = preview,
|
||||||
@@ -287,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()
|
||||||
}
|
}
|
||||||
@@ -299,11 +326,9 @@ class ChatRoomService(
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
|
fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean {
|
||||||
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)
|
participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
|
||||||
if (participant == null) {
|
?: throw SodaException(messageKey = "common.error.access_denied")
|
||||||
throw SodaException("잘못된 접근입니다")
|
|
||||||
}
|
|
||||||
return fetchSessionActive(room.sessionId)
|
return fetchSessionActive(room.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +336,7 @@ class ChatRoomService(
|
|||||||
fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse {
|
fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse {
|
||||||
// 1) 활성 여부 무관하게 방 조회
|
// 1) 활성 여부 무관하게 방 조회
|
||||||
val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow {
|
||||||
SodaException("채팅방을 찾을 수 없습니다.")
|
SodaException(messageKey = "chat.error.room_not_found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 기본 방 기준 참여/활성 여부 확인
|
// 2) 기본 방 기준 참여/활성 여부 확인
|
||||||
@@ -325,10 +350,10 @@ class ChatRoomService(
|
|||||||
ParticipantType.CHARACTER
|
ParticipantType.CHARACTER
|
||||||
) ?: baseRoom.participants.firstOrNull {
|
) ?: baseRoom.participants.firstOrNull {
|
||||||
it.participantType == ParticipantType.CHARACTER
|
it.participantType == ParticipantType.CHARACTER
|
||||||
} ?: throw SodaException("잘못된 접근입니다")
|
} ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val baseCharacter = baseCharacterParticipant.character
|
val baseCharacter = baseCharacterParticipant.character
|
||||||
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "common.error.unknown")
|
||||||
|
|
||||||
// 4) 유효한 입장 대상 방 결정
|
// 4) 유효한 입장 대상 방 결정
|
||||||
val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) {
|
val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) {
|
||||||
@@ -338,9 +363,9 @@ class ChatRoomService(
|
|||||||
val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter)
|
val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter)
|
||||||
alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리
|
alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리
|
||||||
if (!isActiveRoom) {
|
if (!isActiveRoom) {
|
||||||
throw SodaException("채팅방을 찾을 수 없습니다.")
|
throw SodaException(messageKey = "chat.error.room_not_found")
|
||||||
} else {
|
} else {
|
||||||
throw SodaException("잘못된 접근입니다")
|
throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -351,10 +376,10 @@ class ChatRoomService(
|
|||||||
ParticipantType.CHARACTER
|
ParticipantType.CHARACTER
|
||||||
) ?: effectiveRoom.participants.firstOrNull {
|
) ?: effectiveRoom.participants.firstOrNull {
|
||||||
it.participantType == ParticipantType.CHARACTER
|
it.participantType == ParticipantType.CHARACTER
|
||||||
} ?: throw SodaException("잘못된 접근입니다")
|
} ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val character = characterParticipant.character
|
val character = characterParticipant.character
|
||||||
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "common.error.unknown")
|
||||||
|
|
||||||
val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}"
|
val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}"
|
||||||
val characterDto = ChatRoomEnterCharacterDto(
|
val characterDto = ChatRoomEnterCharacterDto(
|
||||||
@@ -495,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
|
||||||
@@ -574,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)
|
||||||
}
|
}
|
||||||
@@ -586,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) {
|
||||||
@@ -621,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!!)
|
||||||
@@ -818,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(
|
||||||
@@ -860,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
|
||||||
}
|
}
|
||||||
@@ -888,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(
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
class SodaException(message: String, val errorProperty: String? = null) : RuntimeException(message)
|
class SodaException(
|
||||||
|
message: String? = null,
|
||||||
|
val errorProperty: String? = null,
|
||||||
|
val messageKey: String? = null
|
||||||
|
) : RuntimeException(message)
|
||||||
|
|
||||||
class AdsChargeException(message: String) : RuntimeException(message)
|
class AdsChargeException(
|
||||||
|
message: String? = null,
|
||||||
|
val messageKey: String? = null
|
||||||
|
) : RuntimeException(message)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.dao.DataIntegrityViolationException
|
import org.springframework.dao.DataIntegrityViolationException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
@@ -13,14 +15,20 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException
|
|||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
|
||||||
@RestControllerAdvice
|
@RestControllerAdvice
|
||||||
class SodaExceptionHandler {
|
class SodaExceptionHandler(
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val messageSource: SodaMessageSource
|
||||||
|
) {
|
||||||
private val logger = LoggerFactory.getLogger(this::class.java)
|
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||||
|
|
||||||
@ExceptionHandler(SodaException::class)
|
@ExceptionHandler(SodaException::class)
|
||||||
fun handleSodaException(e: SodaException) = run {
|
fun handleSodaException(e: SodaException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
|
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
|
||||||
|
?: e.message?.takeIf { it.isNotBlank() }
|
||||||
|
?: messageSource.getMessage("common.error.unknown", langContext.lang)
|
||||||
ApiResponse.error(
|
ApiResponse.error(
|
||||||
message = e.message,
|
message = message,
|
||||||
errorProperty = e.errorProperty
|
errorProperty = e.errorProperty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -28,44 +36,53 @@ class SodaExceptionHandler {
|
|||||||
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
@ExceptionHandler(MaxUploadSizeExceededException::class)
|
||||||
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
fun handleMaxUploadSizeExceededException(e: MaxUploadSizeExceededException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error(message = "파일용량은 최대 1024MB까지 저장할 수 있습니다.")
|
val message = messageSource.getMessage("common.error.max_upload_size", langContext.lang)
|
||||||
|
ApiResponse.error(message = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(AccessDeniedException::class)
|
@ExceptionHandler(AccessDeniedException::class)
|
||||||
fun handleAccessDeniedException(e: AccessDeniedException) = run {
|
fun handleAccessDeniedException(e: AccessDeniedException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error(message = "권한이 없습니다.")
|
val message = messageSource.getMessage("common.error.access_denied", langContext.lang)
|
||||||
|
ApiResponse.error(message = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(InternalAuthenticationServiceException::class)
|
@ExceptionHandler(InternalAuthenticationServiceException::class)
|
||||||
fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run {
|
fun handleInternalAuthenticationServiceException(e: InternalAuthenticationServiceException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("로그인 정보를 확인해주세요.")
|
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
|
||||||
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(BadCredentialsException::class)
|
@ExceptionHandler(BadCredentialsException::class)
|
||||||
fun handleBadCredentialsException(e: BadCredentialsException) = run {
|
fun handleBadCredentialsException(e: BadCredentialsException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("로그인 정보를 확인해주세요.")
|
val message = messageSource.getMessage("common.error.bad_credentials", langContext.lang)
|
||||||
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(DataIntegrityViolationException::class)
|
@ExceptionHandler(DataIntegrityViolationException::class)
|
||||||
fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run {
|
fun handleDataIntegrityViolationException(e: DataIntegrityViolationException) = run {
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("이미 등록되어 있습니다.")
|
val message = messageSource.getMessage("common.error.already_registered", langContext.lang)
|
||||||
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResponseStatus(value = HttpStatus.NOT_FOUND)
|
@ResponseStatus(value = HttpStatus.NOT_FOUND)
|
||||||
@ExceptionHandler(AdsChargeException::class)
|
@ExceptionHandler(AdsChargeException::class)
|
||||||
fun handleAdsChargeException(e: AdsChargeException) = run {
|
fun handleAdsChargeException(e: AdsChargeException) = run {
|
||||||
logger.error("API error - AdsChargeException ::: ", e)
|
logger.error("API error - AdsChargeException ::: ", e)
|
||||||
ApiResponse.error("잘못된 요청입니다.")
|
val message = e.messageKey?.takeIf { it.isNotBlank() }?.let { messageSource.getMessage(it, langContext.lang) }
|
||||||
|
?: e.message?.takeIf { it.isNotBlank() }
|
||||||
|
?: messageSource.getMessage("common.error.invalid_request", langContext.lang)
|
||||||
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception::class)
|
@ExceptionHandler(Exception::class)
|
||||||
fun handleException(e: Exception) = run {
|
fun handleException(e: Exception) = run {
|
||||||
if (e is ResponseStatusException) throw e
|
if (e is ResponseStatusException) throw e
|
||||||
logger.error("API error", e)
|
logger.error("API error", e)
|
||||||
ApiResponse.error("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.")
|
val message = messageSource.getMessage("common.error.unknown", langContext.lang)
|
||||||
|
ApiResponse.error(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
package kr.co.vividnext.sodalive.configs
|
package kr.co.vividnext.sodalive.configs
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class WebConfig : WebMvcConfigurer {
|
class WebConfig(
|
||||||
|
private val langInterceptor: LangInterceptor
|
||||||
|
) : WebMvcConfigurer {
|
||||||
|
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||||
|
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
|
||||||
|
}
|
||||||
|
|
||||||
override fun addCorsMappings(registry: CorsRegistry) {
|
override fun addCorsMappings(registry: CorsRegistry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(
|
.allowedOrigins(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ data class AudioContent(
|
|||||||
var title: String,
|
var title: String,
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var detail: String,
|
var detail: String,
|
||||||
|
var languageCode: String?,
|
||||||
var playCount: Long = 0,
|
var playCount: Long = 0,
|
||||||
var price: Int = 0,
|
var price: Int = 0,
|
||||||
var releaseDate: LocalDateTime? = null,
|
var releaseDate: LocalDateTime? = null,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestPart("request") requestString: String,
|
@RequestPart("request") requestString: String,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.createAudioContent(
|
service.createAudioContent(
|
||||||
@@ -57,7 +57,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestPart("request") requestString: String,
|
@RequestPart("request") requestString: String,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.modifyAudioContent(
|
service.modifyAudioContent(
|
||||||
@@ -74,7 +74,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestBody request: UploadCompleteRequest,
|
@RequestBody request: UploadCompleteRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.uploadComplete(
|
service.uploadComplete(
|
||||||
@@ -91,7 +91,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.deleteAudioContent(
|
service.deleteAudioContent(
|
||||||
@@ -111,7 +111,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getAudioContentList(
|
service.getAudioContentList(
|
||||||
@@ -134,7 +134,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getDetail(
|
service.getDetail(
|
||||||
@@ -151,7 +151,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@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")
|
||||||
ApiResponse.ok(service.generateUrl(contentId = id, member = member))
|
ApiResponse.ok(service.generateUrl(contentId = id, member = member))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestBody request: AddAllPlaybackTrackingRequest,
|
@RequestBody request: AddAllPlaybackTrackingRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.addAllPlaybackTracking(request, member))
|
ApiResponse.ok(service.addAllPlaybackTracking(request, member))
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestBody request: PutAudioContentLikeRequest,
|
@RequestBody request: PutAudioContentLikeRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.audioContentLike(request, member))
|
ApiResponse.ok(service.audioContentLike(request, member))
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
fun getAudioContentRankingSort(
|
fun getAudioContentRankingSort(
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.getContentRankingSortTypeList())
|
ApiResponse.ok(service.getContentRankingSortTypeList())
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.pinToTheTop(contentId = id, member = member))
|
ApiResponse.ok(service.pinToTheTop(contentId = id, member = member))
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member))
|
ApiResponse.ok(service.unpinAtTheTop(contentId = id, member = member))
|
||||||
}
|
}
|
||||||
@@ -248,7 +248,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getLatestContentByTheme(
|
service.getLatestContentByTheme(
|
||||||
|
|||||||
@@ -21,10 +21,21 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
|||||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
@@ -56,11 +67,19 @@ class AudioContentService(
|
|||||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||||
private val pinContentRepository: PinContentRepository,
|
private val pinContentRepository: PinContentRepository,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val audioContentCloudFront: AudioContentCloudFront,
|
private val audioContentCloudFront: AudioContentCloudFront,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
|
private val messageSource: SodaMessageSource,
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
private val audioContentBucket: String,
|
private val audioContentBucket: String,
|
||||||
|
|
||||||
@@ -100,7 +119,7 @@ class AudioContentService(
|
|||||||
val request = objectMapper.readValue(requestString, ModifyAudioContentRequest::class.java)
|
val request = objectMapper.readValue(requestString, ModifyAudioContentRequest::class.java)
|
||||||
|
|
||||||
val audioContent = repository.findByIdAndCreatorId(request.contentId, member.id!!)
|
val audioContent = repository.findByIdAndCreatorId(request.contentId, member.id!!)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
if (request.title != null) audioContent.title = request.title
|
if (request.title != null) audioContent.title = request.title
|
||||||
if (request.detail != null) audioContent.detail = request.detail
|
if (request.detail != null) audioContent.detail = request.detail
|
||||||
@@ -160,12 +179,19 @@ class AudioContentService(
|
|||||||
|
|
||||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteAudioContent(audioContentId: Long, member: Member) {
|
fun deleteAudioContent(audioContentId: Long, member: Member) {
|
||||||
val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!)
|
val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
audioContent.isActive = false
|
audioContent.isActive = false
|
||||||
audioContent.releaseDate = null
|
audioContent.releaseDate = null
|
||||||
@@ -179,7 +205,7 @@ class AudioContentService(
|
|||||||
member: Member
|
member: Member
|
||||||
): CreateAudioContentResponse {
|
): CreateAudioContentResponse {
|
||||||
// coverImage 체크
|
// coverImage 체크
|
||||||
if (coverImage == null) throw SodaException("커버이미지를 선택해 주세요.")
|
if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required")
|
||||||
|
|
||||||
// request 내용 파싱
|
// request 내용 파싱
|
||||||
val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java)
|
||||||
@@ -198,18 +224,18 @@ class AudioContentService(
|
|||||||
|
|
||||||
// contentFile 체크
|
// contentFile 체크
|
||||||
if (contentFile == null) {
|
if (contentFile == null) {
|
||||||
throw SodaException("콘텐츠를 선택해 주세요.")
|
throw SodaException(messageKey = "content.error.content_required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테마 체크
|
// 테마 체크
|
||||||
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
|
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
|
||||||
?: throw SodaException("잘못된 테마입니다. 다시 선택해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_theme")
|
||||||
|
|
||||||
if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) {
|
if ((request.themeId == 12L || request.themeId == 13L || request.themeId == 14L) && request.price < 5) {
|
||||||
throw SodaException("알람, 모닝콜, 슬립콜 테마의 콘텐츠는 5캔 이상의 유료콘텐츠로 등록이 가능합니다.")
|
throw SodaException(messageKey = "content.error.alarm_theme_price_min")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.price in 1..4) throw SodaException("콘텐츠의 최소금액은 5캔 입니다.")
|
if (request.price in 1..4) throw SodaException(messageKey = "content.error.minimum_price")
|
||||||
|
|
||||||
val isFullDetailVisible = if (request.price >= 50) {
|
val isFullDetailVisible = if (request.price >= 50) {
|
||||||
request.isFullDetailVisible
|
request.isFullDetailVisible
|
||||||
@@ -238,6 +264,7 @@ class AudioContentService(
|
|||||||
val audioContent = AudioContent(
|
val audioContent = AudioContent(
|
||||||
title = request.title.trim(),
|
title = request.title.trim(),
|
||||||
detail = request.detail.trim(),
|
detail = request.detail.trim(),
|
||||||
|
languageCode = request.languageCode,
|
||||||
price = if (request.price > 0) {
|
price = if (request.price > 0) {
|
||||||
request.price
|
request.price
|
||||||
} else {
|
} else {
|
||||||
@@ -331,6 +358,31 @@ class AudioContentService(
|
|||||||
|
|
||||||
audioContent.content = contentPath
|
audioContent.content = contentPath
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (audioContent.languageCode.isNullOrBlank()) {
|
||||||
|
val papagoQuery = listOf(
|
||||||
|
request.title.trim(),
|
||||||
|
request.detail.trim(),
|
||||||
|
request.tags.trim()
|
||||||
|
)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" ")
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
query = papagoQuery
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,34 +390,34 @@ class AudioContentService(
|
|||||||
if (previewStartTime != null && previewEndTime != null) {
|
if (previewStartTime != null && previewEndTime != null) {
|
||||||
val startTimeArray = previewStartTime.split(":")
|
val startTimeArray = previewStartTime.split(":")
|
||||||
if (startTimeArray.size != 3) {
|
if (startTimeArray.size != 3) {
|
||||||
throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다")
|
throw SodaException(messageKey = "content.error.preview_time_format")
|
||||||
}
|
}
|
||||||
|
|
||||||
for (time in startTimeArray) {
|
for (time in startTimeArray) {
|
||||||
if (time.length != 2) {
|
if (time.length != 2) {
|
||||||
throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다")
|
throw SodaException(messageKey = "content.error.preview_time_format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val endTimeArray = previewEndTime.split(":")
|
val endTimeArray = previewEndTime.split(":")
|
||||||
if (endTimeArray.size != 3) {
|
if (endTimeArray.size != 3) {
|
||||||
throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다")
|
throw SodaException(messageKey = "content.error.preview_time_format")
|
||||||
}
|
}
|
||||||
|
|
||||||
for (time in endTimeArray) {
|
for (time in endTimeArray) {
|
||||||
if (time.length != 2) {
|
if (time.length != 2) {
|
||||||
throw SodaException("미리 듣기 시간 형식은 00:30:00 과 같아야 합니다")
|
throw SodaException(messageKey = "content.error.preview_time_format")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeDifference = timeDifference(previewStartTime, previewEndTime)
|
val timeDifference = timeDifference(previewStartTime, previewEndTime)
|
||||||
|
|
||||||
if (timeDifference < 15000) {
|
if (timeDifference < 15000) {
|
||||||
throw SodaException("미리 듣기의 최소 시간은 15초 입니다.")
|
throw SodaException(messageKey = "content.error.preview_time_minimum")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (previewStartTime != null || previewEndTime != null) {
|
if (previewStartTime != null || previewEndTime != null) {
|
||||||
throw SodaException("미리 듣기 시작 시간과 종료 시간 둘 다 입력을 하거나 둘 다 입력 하지 않아야 합니다.")
|
throw SodaException(messageKey = "content.error.preview_time_both_required")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,10 +447,10 @@ class AudioContentService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun uploadComplete(contentId: Long, content: String, duration: String) {
|
fun uploadComplete(contentId: Long, content: String, duration: String) {
|
||||||
val keyFileName = content.split("/").last()
|
val keyFileName = content.split("/").last()
|
||||||
if (!keyFileName.startsWith(contentId.toString())) throw SodaException("잘못된 요청입니다.")
|
if (!keyFileName.startsWith(contentId.toString())) throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
val audioContent = repository.findByIdOrNull(contentId)
|
val audioContent = repository.findByIdOrNull(contentId)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
audioContent.content = content
|
audioContent.content = content
|
||||||
audioContent.duration = duration
|
audioContent.duration = duration
|
||||||
@@ -406,7 +458,7 @@ class AudioContentService(
|
|||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.INDIVIDUAL,
|
type = FcmEventType.INDIVIDUAL,
|
||||||
title = "콘텐츠 등록완료",
|
title = formatMessage("content.notification.upload_complete_title"),
|
||||||
message = audioContent.title,
|
message = audioContent.title,
|
||||||
recipients = listOf(audioContent.member!!.id!!),
|
recipients = listOf(audioContent.member!!.id!!),
|
||||||
isAuth = null,
|
isAuth = null,
|
||||||
@@ -421,7 +473,7 @@ class AudioContentService(
|
|||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}",
|
message = formatMessage("content.notification.uploaded_message", audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
creatorId = audioContent.member!!.id,
|
creatorId = audioContent.member!!.id,
|
||||||
@@ -433,7 +485,7 @@ class AudioContentService(
|
|||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}",
|
message = formatMessage("content.notification.uploaded_message", audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
creatorId = audioContent.member!!.id,
|
creatorId = audioContent.member!!.id,
|
||||||
@@ -455,7 +507,7 @@ class AudioContentService(
|
|||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}",
|
message = formatMessage("content.notification.uploaded_message", audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
creatorId = audioContent.member!!.id,
|
creatorId = audioContent.member!!.id,
|
||||||
@@ -467,7 +519,7 @@ class AudioContentService(
|
|||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.UPLOAD_CONTENT,
|
type = FcmEventType.UPLOAD_CONTENT,
|
||||||
title = audioContent.member!!.nickname,
|
title = audioContent.member!!.nickname,
|
||||||
message = "콘텐츠를 업로드 하였습니다. - ${audioContent.title}",
|
message = formatMessage("content.notification.uploaded_message", audioContent.title),
|
||||||
isAuth = audioContent.isAdult,
|
isAuth = audioContent.isAdult,
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
creatorId = audioContent.member!!.id,
|
creatorId = audioContent.member!!.id,
|
||||||
@@ -477,6 +529,7 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
fun getDetail(
|
fun getDetail(
|
||||||
id: Long,
|
id: Long,
|
||||||
member: Member,
|
member: Member,
|
||||||
@@ -487,12 +540,12 @@ class AudioContentService(
|
|||||||
|
|
||||||
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
|
// 오디오 콘텐츠 조회 (content_id, 제목, 내용, 테마, 태그, 19여부, 이미지, 콘텐츠 PATH)
|
||||||
val audioContent = repository.findByIdOrNull(id)
|
val audioContent = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
// 크리에이터(유저) 정보
|
// 크리에이터(유저) 정보
|
||||||
val creatorId = audioContent.member!!.id!!
|
val creatorId = audioContent.member!!.id!!
|
||||||
val creator = explorerQueryRepository.getMember(creatorId)
|
val creator = explorerQueryRepository.getMember(creatorId)
|
||||||
?: throw SodaException("없는 사용자 입니다.")
|
?: throw SodaException(messageKey = "content.error.user_not_found")
|
||||||
|
|
||||||
val creatorFollowing = explorerQueryRepository.getCreatorFollowing(
|
val creatorFollowing = explorerQueryRepository.getCreatorFollowing(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
@@ -506,7 +559,9 @@ class AudioContentService(
|
|||||||
|
|
||||||
// 차단된 사용자 체크
|
// 차단된 사용자 체크
|
||||||
val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId)
|
val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = creatorId)
|
||||||
if (isBlocked && !isExistsAudioContent) throw SodaException("${creator.nickname}님의 요청으로 콘텐츠 접근이 제한됩니다.")
|
if (isBlocked && !isExistsAudioContent) {
|
||||||
|
throw SodaException(formatMessage("content.error.access_restricted_by_creator", creator.nickname))
|
||||||
|
}
|
||||||
|
|
||||||
val orderSequence = if (isExistsAudioContent) {
|
val orderSequence = if (isExistsAudioContent) {
|
||||||
limitedEditionOrderRepository.getOrderSequence(
|
limitedEditionOrderRepository.getOrderSequence(
|
||||||
@@ -544,7 +599,7 @@ class AudioContentService(
|
|||||||
audioContent.releaseDate != null &&
|
audioContent.releaseDate != null &&
|
||||||
audioContent.releaseDate!! < LocalDateTime.now()
|
audioContent.releaseDate!! < LocalDateTime.now()
|
||||||
) {
|
) {
|
||||||
throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 댓글
|
// 댓글
|
||||||
@@ -577,11 +632,13 @@ class AudioContentService(
|
|||||||
audioContent.releaseDate != null &&
|
audioContent.releaseDate != null &&
|
||||||
audioContent.releaseDate!! >= LocalDateTime.now()
|
audioContent.releaseDate!! >= LocalDateTime.now()
|
||||||
) {
|
) {
|
||||||
|
val releaseDatePattern = messageSource.getMessage("content.release_date.format", langContext.lang)
|
||||||
|
?: "yyyy년 MM월 dd일 HH시 mm분 오픈예정"
|
||||||
audioContent.releaseDate!!
|
audioContent.releaseDate!!
|
||||||
.atZone(ZoneId.of("UTC"))
|
.atZone(ZoneId.of("UTC"))
|
||||||
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH시 mm분 오픈예정"))
|
.format(DateTimeFormatter.ofPattern(releaseDatePattern, langContext.lang.locale))
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -699,13 +756,108 @@ class AudioContentService(
|
|||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var translated: TranslatedContent? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* audioContent.languageCode != languageCode
|
||||||
|
*
|
||||||
|
* 번역 콘텐츠를 조회한다. - contentId, locale
|
||||||
|
* 번역 콘텐츠가 있으면
|
||||||
|
* TranslatedContent로 가공한다
|
||||||
|
*
|
||||||
|
* 번역 콘텐츠가 없으면
|
||||||
|
* 파파고 API를 통해 번역한 후 저장한다.
|
||||||
|
*
|
||||||
|
* 번역 대상: title, detail, tags
|
||||||
|
*
|
||||||
|
* 파파고로 번역한 데이터를 TranslatedContent로 가공한다
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
audioContent.languageCode != null &&
|
||||||
|
audioContent.languageCode!!.isNotBlank() &&
|
||||||
|
audioContent.languageCode != langContext.lang.code
|
||||||
|
) {
|
||||||
|
val existing = contentTranslationRepository
|
||||||
|
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
val payload = existing.renderedPayload
|
||||||
|
translated = TranslatedContent(
|
||||||
|
title = payload.title,
|
||||||
|
detail = payload.detail,
|
||||||
|
tags = payload.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(audioContent.title)
|
||||||
|
texts.add(audioContent.detail)
|
||||||
|
texts.add(tag)
|
||||||
|
|
||||||
|
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = langContext.lang.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedTitle = translatedTexts[index++]
|
||||||
|
val translatedDetail = translatedTexts[index++]
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = ContentTranslationPayload(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
contentTranslationRepository.save(
|
||||||
|
ContentTranslation(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
locale = langContext.lang.code,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
translated = TranslatedContent(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* themeStr 번역 처리
|
||||||
|
*/
|
||||||
|
val themeStrTranslated = run {
|
||||||
|
val theme = audioContent.theme
|
||||||
|
if (theme?.id != null) {
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val translatedContentTheme = contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdAndLocale(theme.id!!, locale)
|
||||||
|
val text = translatedContentTheme?.theme
|
||||||
|
if (!text.isNullOrBlank()) text else theme.theme
|
||||||
|
} else {
|
||||||
|
audioContent.theme!!.theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentDetailResponse(
|
return GetAudioContentDetailResponse(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
title = audioContent.title,
|
title = audioContent.title,
|
||||||
detail = contentDetail,
|
detail = contentDetail,
|
||||||
|
languageCode = audioContent.languageCode,
|
||||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||||
contentUrl = audioContentUrl,
|
contentUrl = audioContentUrl,
|
||||||
themeStr = audioContent.theme!!.theme,
|
themeStr = themeStrTranslated,
|
||||||
tag = tag,
|
tag = tag,
|
||||||
price = audioContent.price,
|
price = audioContent.price,
|
||||||
duration = audioContent.duration ?: "",
|
duration = audioContent.duration ?: "",
|
||||||
@@ -745,7 +897,8 @@ class AudioContentService(
|
|||||||
previousContent = previousContent,
|
previousContent = previousContent,
|
||||||
nextContent = nextContent,
|
nextContent = nextContent,
|
||||||
buyerList = buyerList,
|
buyerList = buyerList,
|
||||||
isAvailableUsePoint = audioContent.isPointAvailable
|
isAvailableUsePoint = audioContent.isPointAvailable,
|
||||||
|
translated = translated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,12 +926,34 @@ class AudioContentService(
|
|||||||
contentId = audioContent.id!!
|
contentId = audioContent.id!!
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* themeStr 번역 처리
|
||||||
|
*/
|
||||||
|
val themeStrTranslated = run {
|
||||||
|
val theme = audioContent.theme
|
||||||
|
if (theme?.id != null) {
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val translated = contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdAndLocale(theme.id!!, locale)
|
||||||
|
val text = translated?.theme
|
||||||
|
if (!text.isNullOrBlank()) text else theme.theme
|
||||||
|
} else {
|
||||||
|
audioContent.theme!!.theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentListItem(
|
return GetAudioContentListItem(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage}",
|
coverImageUrl = "$coverImageHost/${audioContent.coverImage}",
|
||||||
title = audioContent.title,
|
title = run {
|
||||||
|
val translatedTitle = contentTranslationRepository
|
||||||
|
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
|
||||||
|
?.renderedPayload
|
||||||
|
?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) audioContent.title else translatedTitle
|
||||||
|
},
|
||||||
price = audioContent.price,
|
price = audioContent.price,
|
||||||
themeStr = audioContent.theme!!.theme,
|
themeStr = themeStrTranslated,
|
||||||
duration = audioContent.duration,
|
duration = audioContent.duration,
|
||||||
likeCount = likeCount,
|
likeCount = likeCount,
|
||||||
commentCount = commentCount,
|
commentCount = commentCount,
|
||||||
@@ -852,9 +1027,60 @@ class AudioContentService(
|
|||||||
it
|
it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val contentIds = items.map { it.contentId }
|
||||||
|
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
items.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
// theme 번역 적용: 번역 데이터가 있으면 번역, 없으면 원문 유지
|
||||||
|
val themeTranslatedList = run {
|
||||||
|
if (translatedContentList.isEmpty()) {
|
||||||
|
translatedContentList
|
||||||
|
} else {
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
|
||||||
|
// 활성 테마 목록에서 한글 원문 -> ID 매핑 구성
|
||||||
|
val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = false,
|
||||||
|
isPointAvailableOnly = false,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||||
|
|
||||||
|
val themeIds = idByKorean.values.distinct()
|
||||||
|
val translatedById = if (themeIds.isNotEmpty()) {
|
||||||
|
contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdInAndLocale(themeIds, locale)
|
||||||
|
.associate { it.contentThemeId to it.theme }
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
translatedContentList.map { item ->
|
||||||
|
val themeId = idByKorean[item.themeStr]
|
||||||
|
val translated = if (themeId != null) translatedById[themeId] else null
|
||||||
|
if (!translated.isNullOrBlank()) item.copy(themeStr = translated) else item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentListResponse(
|
return GetAudioContentListResponse(
|
||||||
totalCount = totalCount,
|
totalCount = totalCount,
|
||||||
items = items
|
items = themeTranslatedList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,8 +1120,13 @@ class AudioContentService(
|
|||||||
limit: Long,
|
limit: Long,
|
||||||
sortType: String = "매출"
|
sortType: String = "매출"
|
||||||
): GetAudioContentRanking {
|
): GetAudioContentRanking {
|
||||||
val startDateFormatter = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")
|
val normalizedSortType = normalizeRankingSortType(sortType)
|
||||||
val endDateFormatter = DateTimeFormatter.ofPattern("MM월 dd일")
|
val startDatePattern = messageSource.getMessage("content.ranking.date.start_format", langContext.lang)
|
||||||
|
?: "yyyy년 MM월 dd일"
|
||||||
|
val endDatePattern = messageSource.getMessage("content.ranking.date.end_format", langContext.lang)
|
||||||
|
?: "MM월 dd일"
|
||||||
|
val startDateFormatter = DateTimeFormatter.ofPattern(startDatePattern, langContext.lang.locale)
|
||||||
|
val endDateFormatter = DateTimeFormatter.ofPattern(endDatePattern, langContext.lang.locale)
|
||||||
|
|
||||||
val contentRankingItemList = repository
|
val contentRankingItemList = repository
|
||||||
.getAudioContentRanking(
|
.getAudioContentRanking(
|
||||||
@@ -906,7 +1137,7 @@ class AudioContentService(
|
|||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
sortType = sortType
|
sortType = normalizedSortType
|
||||||
)
|
)
|
||||||
|
|
||||||
return GetAudioContentRanking(
|
return GetAudioContentRanking(
|
||||||
@@ -917,16 +1148,19 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getContentRankingSortTypeList(): List<String> {
|
fun getContentRankingSortTypeList(): List<String> {
|
||||||
return listOf("매출", "댓글", "좋아요")
|
val salesLabel = messageSource.getMessage("content.ranking.sort_type.sales", langContext.lang) ?: "매출"
|
||||||
|
val commentLabel = messageSource.getMessage("content.ranking.sort_type.comment", langContext.lang) ?: "댓글"
|
||||||
|
val likeLabel = messageSource.getMessage("content.ranking.sort_type.like", langContext.lang) ?: "좋아요"
|
||||||
|
return listOf(salesLabel, commentLabel, likeLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun pinToTheTop(contentId: Long, member: Member) {
|
fun pinToTheTop(contentId: Long, member: Member) {
|
||||||
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!)
|
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) {
|
if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) {
|
||||||
throw SodaException("콘텐츠 오픈 후 채널에 고정이 가능합니다.")
|
throw SodaException(messageKey = "content.error.pin_available_after_open")
|
||||||
}
|
}
|
||||||
|
|
||||||
var pinContent = pinContentRepository.findByContentIdAndMemberId(
|
var pinContent = pinContentRepository.findByContentIdAndMemberId(
|
||||||
@@ -956,14 +1190,14 @@ class AudioContentService(
|
|||||||
val pinContent = pinContentRepository.findByContentIdAndMemberId(
|
val pinContent = pinContentRepository.findByContentIdAndMemberId(
|
||||||
contentId = contentId,
|
contentId = contentId,
|
||||||
memberId = member.id!!
|
memberId = member.id!!
|
||||||
) ?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
) ?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
pinContent.isActive = false
|
pinContent.isActive = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateUrl(contentId: Long, member: Member): GenerateUrlResponse {
|
fun generateUrl(contentId: Long, member: Member): GenerateUrlResponse {
|
||||||
val audioContent = repository.findByIdOrNull(contentId)
|
val audioContent = repository.findByIdOrNull(contentId)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
|
||||||
|
|
||||||
val isExistsAudioContent = orderRepository.isExistOrdered(
|
val isExistsAudioContent = orderRepository.isExistOrdered(
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
@@ -994,8 +1228,20 @@ class AudioContentService(
|
|||||||
orderByRandom: Boolean = false,
|
orderByRandom: Boolean = false,
|
||||||
isPointAvailableOnly: Boolean = false
|
isPointAvailableOnly: Boolean = false
|
||||||
): List<AudioContentMainItem> {
|
): List<AudioContentMainItem> {
|
||||||
return repository.getLatestContentByTheme(
|
/**
|
||||||
theme = theme,
|
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||||
|
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||||
|
*/
|
||||||
|
val normalizedTheme = normalizeThemeForQuery(
|
||||||
|
themes = theme,
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = isFree,
|
||||||
|
isAdult = isAdult,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentList = repository.getLatestContentByTheme(
|
||||||
|
theme = normalizedTheme,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
@@ -1005,5 +1251,103 @@ class AudioContentService(
|
|||||||
orderByRandom = orderByRandom,
|
orderByRandom = orderByRandom,
|
||||||
isPointAvailableOnly = isPointAvailableOnly
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
return if (contentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
contentList.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* theme 파라미터로 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
|
||||||
|
* - 현재 언어(locale)에 해당하는 테마 번역 목록을 활성 테마 집합과 매칭하여 역매핑한다.
|
||||||
|
* - 입력이 이미 한글인 경우 그대로 유지한다.
|
||||||
|
* - 매칭 실패 시 원본 값을 유지한다.
|
||||||
|
*/
|
||||||
|
private fun normalizeThemeForQuery(
|
||||||
|
themes: List<String>,
|
||||||
|
contentType: ContentType,
|
||||||
|
isFree: Boolean,
|
||||||
|
isAdult: Boolean,
|
||||||
|
isPointAvailableOnly: Boolean
|
||||||
|
): List<String> {
|
||||||
|
if (themes.isEmpty()) return themes
|
||||||
|
|
||||||
|
val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = isFree,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (themesWithIds.isEmpty()) return themes
|
||||||
|
|
||||||
|
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||||
|
val koreanById = themesWithIds.associate { it.id to it.theme }
|
||||||
|
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
// 번역 테마를 역매핑하기 위해 현재 locale의 번역 목록을 조회
|
||||||
|
val translatedByTextToId = run {
|
||||||
|
val ids = themesWithIds.map { it.id }
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdInAndLocale(ids, locale)
|
||||||
|
.associate { it.theme to it.contentThemeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return themes.asSequence()
|
||||||
|
.map { input ->
|
||||||
|
when {
|
||||||
|
idByKorean.containsKey(input) -> input // 이미 한글 원문
|
||||||
|
translatedByTextToId.containsKey(input) -> {
|
||||||
|
val id = translatedByTextToId[input]!!
|
||||||
|
koreanById[id] ?: input
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeRankingSortType(sortType: String?): String {
|
||||||
|
val trimmed = sortType?.trim().orEmpty()
|
||||||
|
val internalTypes = setOf("매출", "댓글", "좋아요", "후원")
|
||||||
|
if (trimmed in internalTypes) return trimmed
|
||||||
|
|
||||||
|
val salesLabel = messageSource.getMessage("content.ranking.sort_type.sales", langContext.lang)
|
||||||
|
val commentLabel = messageSource.getMessage("content.ranking.sort_type.comment", langContext.lang)
|
||||||
|
val likeLabel = messageSource.getMessage("content.ranking.sort_type.like", langContext.lang)
|
||||||
|
val donationLabel = messageSource.getMessage("content.ranking.sort_type.donation", langContext.lang)
|
||||||
|
|
||||||
|
return when (trimmed) {
|
||||||
|
salesLabel -> "매출"
|
||||||
|
commentLabel -> "댓글"
|
||||||
|
likeLabel -> "좋아요"
|
||||||
|
donationLabel -> "후원"
|
||||||
|
else -> "매출"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMessage(key: String, vararg args: Any): String {
|
||||||
|
val template = messageSource.getMessage(key, langContext.lang) ?: return ""
|
||||||
|
return String.format(template, *args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
|
|||||||
val isCommentAvailable: Boolean = false,
|
val isCommentAvailable: Boolean = false,
|
||||||
val isFullDetailVisible: Boolean = true,
|
val isFullDetailVisible: Boolean = true,
|
||||||
val previewStartTime: String? = null,
|
val previewStartTime: String? = null,
|
||||||
val previewEndTime: String? = null
|
val previewEndTime: String? = null,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.content
|
|||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
||||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||||
|
|
||||||
data class GetAudioContentDetailResponse(
|
data class GetAudioContentDetailResponse(
|
||||||
val contentId: Long,
|
val contentId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val detail: String,
|
val detail: String,
|
||||||
|
val languageCode: String?,
|
||||||
val coverImageUrl: String,
|
val coverImageUrl: String,
|
||||||
val contentUrl: String,
|
val contentUrl: String,
|
||||||
val themeStr: String,
|
val themeStr: String,
|
||||||
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
|
|||||||
val previousContent: OtherContentResponse?,
|
val previousContent: OtherContentResponse?,
|
||||||
val nextContent: OtherContentResponse?,
|
val nextContent: OtherContentResponse?,
|
||||||
val buyerList: List<ContentBuyer>,
|
val buyerList: List<ContentBuyer>,
|
||||||
val isAvailableUsePoint: Boolean
|
val isAvailableUsePoint: Boolean,
|
||||||
|
val translated: TranslatedContent?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OtherContentResponse @QueryProjection constructor(
|
data class OtherContentResponse @QueryProjection constructor(
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
import org.springframework.util.LinkedMultiValueMap
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||||
|
*/
|
||||||
|
enum class LanguageDetectTargetType {
|
||||||
|
CONTENT,
|
||||||
|
COMMENT,
|
||||||
|
CHARACTER,
|
||||||
|
CHARACTER_COMMENT,
|
||||||
|
CREATOR_CHEERS,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL_WORK,
|
||||||
|
|
||||||
|
CREATOR_CONTENT_CATEGORY
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageDetectEvent(
|
||||||
|
val id: Long,
|
||||||
|
val query: String,
|
||||||
|
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PapagoLanguageDetectResponse(
|
||||||
|
val langCode: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LanguageDetectListener(
|
||||||
|
private val audioContentRepository: AudioContentRepository,
|
||||||
|
private val audioContentCommentRepository: AudioContentCommentRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val characterCommentRepository: CharacterCommentRepository,
|
||||||
|
private val creatorCheersRepository: CreatorCheersRepository,
|
||||||
|
private val seriesRepository: ContentSeriesRepository,
|
||||||
|
private val originalWorkRepository: OriginalWorkRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
|
@Value("\${cloud.naver.papago-client-id}")
|
||||||
|
private val papagoClientId: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.naver.papago-client-secret}")
|
||||||
|
private val papagoClientSecret: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java)
|
||||||
|
|
||||||
|
private val restTemplate: RestTemplate = RestTemplate()
|
||||||
|
|
||||||
|
private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect"
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun detectLanguage(event: LanguageDetectEvent) {
|
||||||
|
if (event.query.isBlank()) {
|
||||||
|
log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.targetType) {
|
||||||
|
LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageDetect(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val characterId = event.id
|
||||||
|
|
||||||
|
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
||||||
|
if (character == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!character.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
||||||
|
characterId,
|
||||||
|
character.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
|
||||||
|
|
||||||
|
character.languageCode = langCode
|
||||||
|
chatCharacterRepository.save(character)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = characterId,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
||||||
|
characterId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleContentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val contentId = event.id
|
||||||
|
|
||||||
|
val audioContent = audioContentRepository.findById(contentId).orElse(null)
|
||||||
|
if (audioContent == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!audioContent.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
|
||||||
|
contentId,
|
||||||
|
audioContent.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
|
||||||
|
|
||||||
|
audioContent.languageCode = langCode
|
||||||
|
|
||||||
|
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
|
||||||
|
audioContentRepository.save(audioContent)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
||||||
|
contentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val commentId = event.id
|
||||||
|
|
||||||
|
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
|
||||||
|
if (comment == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
|
||||||
|
commentId,
|
||||||
|
comment.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||||
|
|
||||||
|
comment.languageCode = langCode
|
||||||
|
audioContentCommentRepository.save(comment)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}",
|
||||||
|
commentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val commentId = event.id
|
||||||
|
|
||||||
|
val comment = characterCommentRepository.findById(commentId).orElse(null)
|
||||||
|
if (comment == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. " +
|
||||||
|
"characterCommentId={}, languageCode={}",
|
||||||
|
commentId,
|
||||||
|
comment.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||||
|
|
||||||
|
comment.languageCode = langCode
|
||||||
|
characterCommentRepository.save(comment)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}",
|
||||||
|
commentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCreatorCheersLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val cheersId = event.id
|
||||||
|
|
||||||
|
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
|
||||||
|
if (cheers == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!cheers.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
|
||||||
|
cheersId,
|
||||||
|
cheers.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
|
||||||
|
|
||||||
|
cheers.languageCode = langCode
|
||||||
|
creatorCheersRepository.save(cheers)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}",
|
||||||
|
cheersId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSeriesLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val seriesId = event.id
|
||||||
|
|
||||||
|
val series = seriesRepository.findByIdOrNull(seriesId)
|
||||||
|
if (series == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!series.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
|
||||||
|
seriesId,
|
||||||
|
series.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
|
||||||
|
|
||||||
|
series.languageCode = langCode
|
||||||
|
seriesRepository.save(series)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = seriesId,
|
||||||
|
targetType = LanguageTranslationTargetType.SERIES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
|
||||||
|
seriesId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val originalWorkId = event.id
|
||||||
|
|
||||||
|
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
|
||||||
|
if (originalWork == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!originalWork.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
|
||||||
|
originalWorkId,
|
||||||
|
originalWork.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
|
||||||
|
|
||||||
|
originalWork.languageCode = langCode
|
||||||
|
originalWorkRepository.save(originalWork)
|
||||||
|
|
||||||
|
// 언어 감지가 완료된 후 언어 번역 이벤트 호출
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = originalWorkId,
|
||||||
|
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}",
|
||||||
|
originalWorkId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCreatorContentCategoryLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val categoryId = event.id
|
||||||
|
|
||||||
|
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
|
||||||
|
if (!category.languageCode.isNullOrBlank()) return
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return
|
||||||
|
|
||||||
|
category.languageCode = langCode
|
||||||
|
categoryRepository.save(category)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = categoryId,
|
||||||
|
targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
|
||||||
|
return try {
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||||
|
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
|
||||||
|
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = LinkedMultiValueMap<String, String>().apply {
|
||||||
|
// 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달
|
||||||
|
add("query", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestEntity = HttpEntity(body, headers)
|
||||||
|
|
||||||
|
val response = restTemplate.postForEntity(
|
||||||
|
papagoDetectUrl,
|
||||||
|
requestEntity,
|
||||||
|
PapagoLanguageDetectResponse::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
log.warn(
|
||||||
|
"[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}",
|
||||||
|
response.statusCode,
|
||||||
|
targetIdForLog
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = response.body?.langCode?.takeIf { it.isNotBlank() }
|
||||||
|
if (langCode == null) {
|
||||||
|
log.warn(
|
||||||
|
"[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}",
|
||||||
|
targetIdForLog
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
langCode
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다.
|
||||||
|
log.error(
|
||||||
|
"[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}",
|
||||||
|
targetIdForLog,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import javax.persistence.JoinColumn
|
|||||||
import javax.persistence.ManyToOne
|
import javax.persistence.ManyToOne
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class Category(
|
class Category(
|
||||||
var title: String,
|
var title: String,
|
||||||
var orders: Int = 1,
|
var orders: Int = 1,
|
||||||
|
var languageCode: String? = null,
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class CategoryController(private val service: CategoryService) {
|
|||||||
@RequestBody request: CreateCategoryRequest,
|
@RequestBody request: CreateCategoryRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.createCategory(request = request, member = member))
|
ApiResponse.ok(service.createCategory(request = request, member = member))
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ class CategoryController(private val service: CategoryService) {
|
|||||||
@RequestBody request: ModifyCategoryRequest,
|
@RequestBody request: ModifyCategoryRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.modifyCategory(request = request, member = member))
|
ApiResponse.ok(service.modifyCategory(request = request, member = member))
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class CategoryController(private val service: CategoryService) {
|
|||||||
@RequestBody request: UpdateCategoryOrdersRequest,
|
@RequestBody request: UpdateCategoryOrdersRequest,
|
||||||
@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")
|
||||||
|
|
||||||
ApiResponse.ok(service.updateCategoryOrders(request = request, member = member))
|
ApiResponse.ok(service.updateCategoryOrders(request = request, member = member))
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ class CategoryController(private val service: CategoryService) {
|
|||||||
@PathVariable("id") categoryId: Long,
|
@PathVariable("id") categoryId: 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")
|
||||||
|
|
||||||
ApiResponse.ok(service.deleteCategory(categoryId = categoryId, member = member))
|
ApiResponse.ok(service.deleteCategory(categoryId = categoryId, member = member))
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ class CategoryController(private val service: CategoryService) {
|
|||||||
@RequestParam creatorId: Long,
|
@RequestParam creatorId: 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")
|
||||||
|
|
||||||
ApiResponse.ok(service.getCategoryList(creatorId = creatorId, memberId = member.id!!))
|
ApiResponse.ok(service.getCategoryList(creatorId = creatorId, memberId = member.id!!))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ package kr.co.vividnext.sodalive.content.category
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@@ -12,7 +20,12 @@ class CategoryService(
|
|||||||
private val repository: CategoryRepository,
|
private val repository: CategoryRepository,
|
||||||
private val contentRepository: AudioContentRepository,
|
private val contentRepository: AudioContentRepository,
|
||||||
private val blockMemberRepository: BlockMemberRepository,
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
private val categoryContentRepository: CategoryContentRepository
|
private val categoryContentRepository: CategoryContentRepository,
|
||||||
|
private val categoryTranslationRepository: CategoryTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
private val translationService: PapagoTranslationService
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCategory(request: CreateCategoryRequest, member: Member) {
|
fun createCategory(request: CreateCategoryRequest, member: Member) {
|
||||||
@@ -40,16 +53,31 @@ class CategoryService(
|
|||||||
)
|
)
|
||||||
categoryContent.isActive = true
|
categoryContent.isActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = category.id!!,
|
||||||
|
query = request.title,
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_CONTENT_CATEGORY
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun modifyCategory(request: ModifyCategoryRequest, member: Member) {
|
fun modifyCategory(request: ModifyCategoryRequest, member: Member) {
|
||||||
val category = repository.findByIdAndMemberId(categoryId = request.categoryId, memberId = member.id!!)
|
val category = repository.findByIdAndMemberId(categoryId = request.categoryId, memberId = member.id!!)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
|
|
||||||
if (!request.title.isNullOrBlank()) {
|
if (!request.title.isNullOrBlank()) {
|
||||||
validateTitle(title = request.title)
|
validateTitle(title = request.title)
|
||||||
category.title = request.title
|
category.title = request.title
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.categoryId,
|
||||||
|
targetType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (contentId in request.addContentIdList) {
|
for (contentId in request.addContentIdList) {
|
||||||
@@ -80,7 +108,7 @@ class CategoryService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCategory(categoryId: Long, member: Member) {
|
fun deleteCategory(categoryId: Long, member: Member) {
|
||||||
val category = repository.findByIdAndMemberId(categoryId = categoryId, memberId = member.id!!)
|
val category = repository.findByIdAndMemberId(categoryId = categoryId, memberId = member.id!!)
|
||||||
?: throw SodaException("잘못된 요청입니다.")
|
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||||
category.isActive = false
|
category.isActive = false
|
||||||
|
|
||||||
categoryContentRepository.deleteByCategoryId(categoryId = categoryId)
|
categoryContentRepository.deleteByCategoryId(categoryId = categoryId)
|
||||||
@@ -97,14 +125,86 @@ class CategoryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
fun getCategoryList(creatorId: Long, memberId: Long): List<GetCategoryListResponse> {
|
fun getCategoryList(creatorId: Long, memberId: Long): List<GetCategoryListResponse> {
|
||||||
val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId)
|
val isBlocked = blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = creatorId)
|
||||||
if (isBlocked) throw SodaException("잘못된 접근입니다.")
|
if (isBlocked) throw SodaException(messageKey = "category.error.invalid_access")
|
||||||
|
|
||||||
return repository.findByCreatorId(creatorId = creatorId)
|
// 기본 카테고리 목록 조회 (원본 언어 기준)
|
||||||
|
val baseList = repository.findByCreatorId(creatorId = creatorId)
|
||||||
|
if (baseList.isEmpty()) return baseList
|
||||||
|
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
|
||||||
|
// 원본 엔티티를 조회하여 languageCode 파악
|
||||||
|
val categoryIds = baseList.map { it.categoryId }
|
||||||
|
val entities = repository.findAllById(categoryIds)
|
||||||
|
val entityMap = entities.associateBy { it.id!! }
|
||||||
|
|
||||||
|
// 요청 로케일로 이미 저장된 번역 일괄 조회
|
||||||
|
val translations = categoryTranslationRepository
|
||||||
|
.findByCategoryIdInAndLocale(categoryIds, locale)
|
||||||
|
.associateBy { it.categoryId }
|
||||||
|
|
||||||
|
// 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용
|
||||||
|
val result = mutableListOf<GetCategoryListResponse>()
|
||||||
|
for (item in baseList) {
|
||||||
|
val entity = entityMap[item.categoryId]
|
||||||
|
if (entity == null) {
|
||||||
|
result.add(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceLang = entity.languageCode
|
||||||
|
if (!sourceLang.isNullOrBlank() && sourceLang != locale) {
|
||||||
|
val existing = translations[item.categoryId]
|
||||||
|
if (existing != null && !existing.category.isNullOrBlank()) {
|
||||||
|
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = existing.category))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 번역본이 없으면 Papago 번역 후 저장
|
||||||
|
val texts = listOf(entity.title)
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLang,
|
||||||
|
targetLanguage = locale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
val translatedCategory = translatedTexts[0]
|
||||||
|
|
||||||
|
val existingOne = categoryTranslationRepository
|
||||||
|
.findByCategoryIdAndLocale(entity.id!!, locale)
|
||||||
|
if (existingOne == null) {
|
||||||
|
categoryTranslationRepository.save(
|
||||||
|
CategoryTranslation(
|
||||||
|
categoryId = entity.id!!,
|
||||||
|
locale = locale,
|
||||||
|
category = translatedCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existingOne.category = translatedCategory
|
||||||
|
categoryTranslationRepository.save(existingOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = translatedCategory))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 번역이 필요 없거나 실패한 경우 원본 사용
|
||||||
|
result.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validateTitle(title: String) {
|
private fun validateTitle(title: String) {
|
||||||
if (title.length < 2) throw SodaException("카테고리명은 2글자 이상 입력하세요")
|
if (title.length < 2) throw SodaException(messageKey = "category.error.title_min_length")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.category
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Table
|
||||||
|
import javax.persistence.UniqueConstraint
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
uniqueConstraints = [
|
||||||
|
UniqueConstraint(columnNames = ["categoryId", "locale"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class CategoryTranslation(
|
||||||
|
val categoryId: Long,
|
||||||
|
val locale: String,
|
||||||
|
var category: String
|
||||||
|
) : BaseEntity()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.category
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface CategoryTranslationRepository : JpaRepository<CategoryTranslation, Long> {
|
||||||
|
fun findByCategoryIdAndLocale(categoryId: Long, locale: String): CategoryTranslation?
|
||||||
|
|
||||||
|
fun findByCategoryIdInAndLocale(categoryIds: Collection<Long>, locale: String): List<CategoryTranslation>
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
|||||||
data class AudioContentComment(
|
data class AudioContentComment(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var comment: String,
|
var comment: String,
|
||||||
|
var languageCode: String?,
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var donationCan: Int? = null,
|
var donationCan: Int? = null,
|
||||||
val isSecret: Boolean = false,
|
val isSecret: Boolean = false,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user