Compare commits
309 Commits
test
...
3c087bc275
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c087bc275 | |||
| 8ad13c289e | |||
| 7577f48a09 | |||
| 0251906964 | |||
| 2723a5f134 | |||
| c3c60605fd | |||
| 238f704b22 | |||
| 5639d8ac8e | |||
| 9aac591591 | |||
| ffa8e5aebb | |||
| cbbfe014cc | |||
| 83028f7817 | |||
| 70d1795557 | |||
| 8c6c681424 | |||
| 50bc9f4ff3 | |||
| f00ea03fad | |||
| f22e7b9ad1 | |||
| c7ec95f4bb | |||
| 229e7a8ccc | |||
| 3c616474ff | |||
| 56eb6b3ce3 | |||
| 545836d43c | |||
| 219f83dec0 | |||
| a76a841238 | |||
| c26680de84 | |||
| 8fffad9d3a | |||
| f4f0f203a2 | |||
| b7196f5a0c | |||
| 5d33a18890 | |||
| 96186a1a50 | |||
| bc8bc479d1 | |||
| 47595b1291 | |||
| 01a88964df | |||
| 3a2b77379f | |||
| dc4e5f75cd | |||
| d0178d551c | |||
| 827333108d | |||
| 587b90bd27 | |||
| 4dc20c5e90 | |||
| ac25782f2b | |||
| 20437d56e7 | |||
| f0b412828a | |||
| 367faac5c3 | |||
| 84deaaa970 | |||
| a2b39466c2 | |||
| 03586c4005 | |||
| 6ea69e1510 | |||
| 553c6dc539 | |||
| 6cc22f5b6d | |||
| 9103d67cc1 | |||
| 25083fb0e4 | |||
| d2dc045255 | |||
| b8621dfbb0 | |||
| 93633940dd | |||
| b6f5325351 | |||
| 7c32c08f1f | |||
| 1d268da08d | |||
| 797666ae0d | |||
| dcf470997e | |||
| 0974d1dbf8 | |||
| 12a35db6cd | |||
| 9abbb05ad8 | |||
| 1ecaf69b0b | |||
| e334d1e5d9 | |||
| b735e861d0 | |||
| 4eb433d372 | |||
| 2416ae61f3 | |||
| 01fb336985 | |||
| b6af88a732 | |||
| 58a2a17d6d | |||
| 79f5a0f520 | |||
| 7f6c0f7f04 | |||
| f658df4dca | |||
| 9d43b8e23a | |||
| 4270aef79b | |||
| 1c0dc82d44 | |||
| c1e325aadf | |||
| cec87da69d | |||
| f68f24cb2c | |||
| ed094347fc | |||
| b8afdffbe1 | |||
| f6ba79f31c | |||
| 5f3b1663d2 | |||
| 66e786b4bb | |||
| f671114574 | |||
| ce37060d94 | |||
| 7d19a4d184 | |||
| 22f28a2f8a | |||
| ceef9ca979 | |||
| efe8f4f939 | |||
| ba692a1195 | |||
| d732bad042 | |||
| 4c935c3bee | |||
| c160dd791f | |||
| 23cd1b4601 | |||
| 031fc8ba1b | |||
| c6853289ad | |||
| 2497bb69bc | |||
| a58a67e0a2 | |||
| 4315fe12a5 | |||
| 42f10a8899 | |||
| 1e4b47f989 | |||
| ff255dbfae | |||
| dbe9b72feb | |||
| 95a714b391 | |||
| 28f58c7f56 | |||
| 8bd46d8f21 | |||
| e1bb8e54ed | |||
| 1de705b063 | |||
| f6926ad356 | |||
| 2cdbbb1b37 | |||
| 4dce8c8f03 | |||
| 97a5bace6f | |||
| d4d51ec48f | |||
| fb91398462 | |||
| 105dadd798 | |||
| 2abf2837d3 | |||
| 422aa67af6 | |||
| 7fffab6985 | |||
| 5a4be3d2c1 | |||
| f39a7681db | |||
| c60a7580ba | |||
| 97edb56edc | |||
| 6ebca8d22b | |||
| 95371ad934 | |||
| 2c176825fd | |||
| fae7de48d3 | |||
| b8230646a2 | |||
| 43279541dd | |||
| b4791977c1 | |||
| ef917ecc25 | |||
| a93faad951 | |||
| fd001d24d3 | |||
| 7aa5884797 | |||
| 5b237a1547 | |||
| 2e37990d87 | |||
| dd07d724a8 | |||
| 03ce8618e7 | |||
| db1a7a7fd6 | |||
| 36a82d7f53 | |||
| 3a34401113 | |||
| 9927268330 | |||
| c45c97e29d | |||
| c64a315226 | |||
| a4cafca6ab | |||
| 46284a0660 | |||
| 05df86e15a | |||
| 8b433027e2 | |||
| 5bd4ff7610 | |||
| d693c397ea | |||
| 1d8d1ec9a5 | |||
| 5e491f11ee | |||
| 7cedea06ac | |||
| 2e5f750e50 | |||
| 20289cad10 | |||
| e0d64c31c7 | |||
| 8c1b95dc97 | |||
| fb5641343e | |||
| 87765941eb | |||
| 1809862c16 | |||
| 300f784f7d | |||
| 67a045eae6 | |||
| 2a79903a28 | |||
| d3222ce083 | |||
| 406a421742 | |||
| 10bf728faf | |||
| 607617747c | |||
| f0a69eb1a2 | |||
| 6b307a6e17 | |||
| 08d08a934a | |||
| c500c12668 | |||
| 62060adeba | |||
| b2fc75edb8 | |||
| a999dd2085 | |||
| 49f95ab100 | |||
| 1a84d5b30c | |||
| 3b65050632 | |||
| d0df31674c | |||
| 1fe88402e2 | |||
| 67097696e6 | |||
| 8e7e77067a | |||
| 9899390b61 | |||
| 80c476a908 | |||
| 59da1d6e49 | |||
| 5aef7dac33 | |||
| faf7aa06b6 | |||
| 38ef6e5583 | |||
| c0b15b5d94 | |||
| 2cfc067ea1 | |||
| a91db4f956 | |||
| 8a09780a02 | |||
| 45e8ec6505 | |||
| 4554b85914 | |||
| 8aa79c4a9c | |||
| c8d3210b57 | |||
| 2282a49563 | |||
| b82fdfb2c8 | |||
| 2d17eac199 | |||
| e482bc3aad | |||
| ec022b74d1 | |||
| dc42c09ce3 | |||
| 046a34d2a4 | |||
| 9ff6ec1888 | |||
| d2950106ec | |||
| 962f800d2e | |||
| 962107e507 | |||
| 039bd11963 | |||
| 5c250ea4ae | |||
| e3405bcec6 | |||
| 0fd1c2235f | |||
| b20c29b022 | |||
| 12d5dcd298 | |||
| 2c305dc6c6 | |||
| 62f76f7433 | |||
| 858ce524f9 | |||
| 3795fb4a40 | |||
| 0c01aeec50 | |||
| 892206744d | |||
| 9e2c1474db | |||
| 16328f73d9 | |||
| e0d4f53cf4 | |||
| e09a59c5b4 | |||
| 049e654535 | |||
| c927dc4ecd | |||
| fe4ecd0ad8 | |||
| 78d476fe80 | |||
| a11c8465d5 | |||
| 366304a9b7 | |||
| 4356663688 | |||
| 26b55e6fcf | |||
| 0d743f7204 | |||
| 6cbe113b3e | |||
| 6409b69d6c | |||
| c5164c76fc | |||
| baade8e138 | |||
| b848d6b4e0 | |||
| d8139d2ab0 | |||
| e96d8f7469 | |||
| 2acffd8afc | |||
| 3c8e72073c | |||
| 724d7a9d9b | |||
| 2da3b0db78 | |||
| 685ad7afaf | |||
| 264cf75964 | |||
| c773dbc7b5 | |||
| 37cbc64f52 | |||
| cb1dde17bb | |||
| c29988acf4 | |||
| eadbf56dae | |||
| 4b3b455135 | |||
| e6ac177396 | |||
| 3d0e29003f | |||
| 78b9b00f77 | |||
| 0ee7faa551 | |||
| e5fdced681 | |||
| afb99fef64 | |||
| 7dfaa36024 | |||
| 0496f665aa | |||
| 0d19e1be74 | |||
| 4aff0111aa | |||
| 63b3ba2bb2 | |||
| 7444b41f60 | |||
| 8e90dbc8b6 | |||
| 9f70722521 | |||
| 52fae596fa | |||
| ccb67957bc | |||
| fb82538d0d | |||
| 72ee39612e | |||
| 51fd5408dc | |||
| 3fae40fbef | |||
| 0745890af0 | |||
| 4abe1730a7 | |||
| 626f0e6989 | |||
| 9f42d9d173 | |||
| f90a93c4bc | |||
| 8000ad6c6a | |||
| 1f1f1bea1a | |||
| d95460c7cd | |||
| a3d93d4b08 | |||
| 07a92af982 | |||
| f4618877d4 | |||
| 2b914fd222 | |||
| 109e42a5a3 | |||
| fa515ad39c | |||
| f09673a795 | |||
| f71536c614 | |||
| 7bdddc7ae8 | |||
| aa8926a624 | |||
| be71e59be2 | |||
| 4d7753378f | |||
| 60257c4ef4 | |||
| 1e0b79bf62 | |||
| 6883434d0d | |||
| eda2193e64 | |||
| 99bf829c88 | |||
| 5feafe1b48 | |||
| c9292b7d04 | |||
| ae7e1a91c1 | |||
| 3e1887e0d1 | |||
| 474646db47 | |||
| 56f7b6c449 | |||
| 76b2b5f7e3 | |||
| e918d809eb | |||
| 7af059e543 | |||
| 897726e1ec | |||
| 8b98a2dd07 | |||
| cca75420f0 | |||
| 86c627ed1d | |||
| d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
||||
37
AGENTS.md
37
AGENTS.md
@@ -1,37 +0,0 @@
|
||||
> 이 문서는 본 저장소에서 **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"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -70,9 +70,6 @@ dependencies {
|
||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||
|
||||
// file mimetype check
|
||||
implementation("org.apache.tika:tika-core:3.2.0")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
runtimeOnly("com.h2database:h2")
|
||||
runtimeOnly("com.mysql:mysql-connector-j")
|
||||
@@ -89,7 +86,7 @@ allOpen {
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-Xjsr305=strict"
|
||||
jvmTarget = "17"
|
||||
jvmTarget = "11"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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,8 +7,6 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
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 org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -24,8 +22,6 @@ class AdminAuditionService(
|
||||
private val repository: AdminAuditionRepository,
|
||||
private val roleRepository: AdminAuditionRoleRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val langContext: LangContext,
|
||||
private val messageSource: SodaMessageSource,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
@@ -48,7 +44,7 @@ class AdminAuditionService(
|
||||
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||
val audition = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
if (request.title != null) {
|
||||
audition.title = request.title
|
||||
@@ -67,7 +63,7 @@ class AdminAuditionService(
|
||||
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||
request.status == AuditionStatus.NOT_STARTED
|
||||
) {
|
||||
throw SodaException(messageKey = "admin.audition.status_cannot_revert")
|
||||
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
||||
}
|
||||
|
||||
audition.status = request.status
|
||||
@@ -92,14 +88,11 @@ class AdminAuditionService(
|
||||
}
|
||||
|
||||
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(
|
||||
FcmEvent(
|
||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||
title = title,
|
||||
message = message,
|
||||
title = "새로운 오디션 등록!",
|
||||
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
||||
isAuth = audition.isAdult,
|
||||
auditionId = audition.id ?: -1
|
||||
)
|
||||
|
||||
@@ -11,11 +11,11 @@ data class CreateAuditionRequest(
|
||||
) {
|
||||
init {
|
||||
if (title.isBlank()) {
|
||||
throw SodaException(messageKey = "admin.audition.title_required")
|
||||
throw SodaException("오디션 제목을 입력하세요")
|
||||
}
|
||||
|
||||
if (information.isBlank() || information.length < 10) {
|
||||
throw SodaException(messageKey = "admin.audition.information_min_length")
|
||||
throw SodaException("오디션 정보는 최소 10글자 입니다")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class AdminAuditionApplicantService(private val repository: AdminAuditionApplica
|
||||
@Transactional
|
||||
fun deleteAuditionApplicant(id: Long) {
|
||||
val applicant = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
applicant.isActive = false
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class AdminAuditionRoleService(
|
||||
auditionScriptUrl = request.auditionScriptUrl
|
||||
)
|
||||
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
auditionRole.audition = audition
|
||||
repository.save(auditionRole)
|
||||
|
||||
@@ -48,19 +48,15 @@ class AdminAuditionRoleService(
|
||||
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
if (!request.name.isNullOrBlank()) {
|
||||
if (request.name.length < 2) {
|
||||
throw SodaException(messageKey = "admin.audition.role.name_min_length")
|
||||
}
|
||||
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
||||
auditionRole.name = request.name
|
||||
}
|
||||
|
||||
if (!request.information.isNullOrBlank()) {
|
||||
if (request.information.length < 10) {
|
||||
throw SodaException(messageKey = "admin.audition.role.information_min_length")
|
||||
}
|
||||
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
||||
auditionRole.information = request.information
|
||||
}
|
||||
|
||||
|
||||
@@ -10,19 +10,19 @@ data class CreateAuditionRoleRequest(
|
||||
) {
|
||||
init {
|
||||
if (auditionId < 0) {
|
||||
throw SodaException(messageKey = "admin.audition.role.audition_required")
|
||||
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
||||
}
|
||||
|
||||
if (name.isBlank() || name.length < 2) {
|
||||
throw SodaException(messageKey = "admin.audition.role.name_required")
|
||||
throw SodaException("캐릭터명을 입력하세요")
|
||||
}
|
||||
|
||||
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||
throw SodaException(messageKey = "admin.audition.role.script_url_required")
|
||||
throw SodaException("오디션 대본 URL을 입력하세요")
|
||||
}
|
||||
|
||||
if (information.isBlank() || information.length < 10) {
|
||||
throw SodaException(messageKey = "admin.audition.role.information_required")
|
||||
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ data class UpdateAuditionRoleRequest(
|
||||
) {
|
||||
init {
|
||||
if (id < 0) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
throw SodaException("잘못된 요청입니다.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
) : BaseEntity() {
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
var deletedAt: LocalDateTime? = null
|
||||
|
||||
fun softDelete() {
|
||||
this.deletedAt = LocalDateTime.now()
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
this.deletedAt = null
|
||||
}
|
||||
|
||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||
this.subsidy = subsidy
|
||||
this.liveSettlementRatio = live
|
||||
this.contentSettlementRatio = content
|
||||
this.communitySettlementRatio = community
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@PostMapping("/update")
|
||||
fun updateCreatorSettlementRatio(
|
||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||
) = ApiResponse.ok(service.updateCreatorSettlementRatio(request))
|
||||
|
||||
@PostMapping("/delete/{memberId}")
|
||||
fun deleteCreatorSettlementRatio(
|
||||
@PathVariable memberId: Long
|
||||
) = ApiResponse.ok(service.deleteCreatorSettlementRatio(memberId))
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
CreatorSettlementRatioQueryRepository
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
@@ -14,59 +14,19 @@ class CreatorSettlementRatioService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
if (existing != null) {
|
||||
// revive if soft-deleted, then update values
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
return
|
||||
}
|
||||
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
creatorSettlementRatio.member = creator
|
||||
repository.save(creatorSettlementRatio)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
||||
}
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteCreatorSettlementRatio(memberId: Long) {
|
||||
val existing = repository.findByMemberId(memberId)
|
||||
?: throw SodaException(messageKey = "admin.settlement_ratio.not_found")
|
||||
existing.softDelete()
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||
|
||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
data class AdminCanChargeRequest(
|
||||
val memberIds: List<Long>,
|
||||
val memberId: Long,
|
||||
val method: String,
|
||||
val can: Int
|
||||
)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/admin/can")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCanController(private val service: AdminCanService) {
|
||||
@GetMapping
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||
|
||||
|
||||
@@ -1,38 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
import kr.co.vividnext.sodalive.can.QCanResponse
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||
|
||||
interface AdminCanQueryRepository {
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
can1.id,
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(can1.status.eq(status))
|
||||
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||
|
||||
@@ -3,13 +3,11 @@ package kr.co.vividnext.sodalive.admin.can
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class AdminCanRequest(
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: BigDecimal,
|
||||
val currency: String
|
||||
val price: Int
|
||||
) {
|
||||
fun toEntity(): Can {
|
||||
var title = "${can.moneyFormat()} 캔"
|
||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
||||
can = can,
|
||||
rewardCan = rewardCan,
|
||||
price = price,
|
||||
currency = currency,
|
||||
status = CanStatus.SALE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
@@ -21,10 +20,6 @@ class AdminCanService(
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: AdminMemberRepository
|
||||
) {
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveCan(request: AdminCanRequest) {
|
||||
repository.save(request.toEntity())
|
||||
@@ -33,34 +28,29 @@ class AdminCanService(
|
||||
@Transactional
|
||||
fun deleteCan(id: Long) {
|
||||
val can = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
can.status = CanStatus.END_OF_SALE
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun charge(request: AdminCanChargeRequest) {
|
||||
if (request.can <= 0) throw SodaException(messageKey = "admin.can.min_amount")
|
||||
if (request.method.isBlank()) throw SodaException(messageKey = "admin.can.method_required")
|
||||
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||
|
||||
val ids = request.memberIds.distinct()
|
||||
if (ids.isEmpty()) throw SodaException(messageKey = "admin.can.member_ids_required")
|
||||
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||
|
||||
val members = memberRepository.findAllById(ids).toList()
|
||||
if (members.size != ids.size) throw SodaException(messageKey = "admin.can.invalid_member_ids")
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
|
||||
members.forEach { member ->
|
||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||
charge.title = "${request.can.moneyFormat()} 캔"
|
||||
charge.member = member
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
|
||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||
payment.method = request.method
|
||||
charge.payment = payment
|
||||
chargeRepository.save(charge)
|
||||
|
||||
chargeRepository.save(charge)
|
||||
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
member.pgRewardCan += charge.rewardCan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
||||
@GetMapping("/detail")
|
||||
fun getChargeStatusDetail(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam paymentGateway: PaymentGateway,
|
||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||
@RequestParam paymentGateway: PaymentGateway
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.BooleanBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
QGetChargeStatusQueryDto(
|
||||
formattedDate,
|
||||
payment.price.sum(),
|
||||
can1.price.sum(),
|
||||
payment.id.count(),
|
||||
payment.paymentGateway.stringValue(),
|
||||
currency.coalesce("KRW")
|
||||
payment.paymentGateway
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||
.groupBy(formattedDate, payment.paymentGateway)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
val currency = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
Expressions.stringTemplate("'합계'"), // date
|
||||
payment.price.sum(),
|
||||
payment.id.count(),
|
||||
Expressions.stringTemplate("''"),
|
||||
currency
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
.innerJoin(payment.charge, charge)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(currency)
|
||||
.orderBy(currency.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
@@ -100,20 +67,6 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d %H:%i:%s"
|
||||
)
|
||||
val currencyExpr = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
val whereBuilder = BooleanBuilder()
|
||||
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
|
||||
if (currency != null) {
|
||||
whereBuilder.and(currencyExpr.eq(currency))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
member.nickname,
|
||||
payment.method.coalesce(""),
|
||||
payment.price,
|
||||
currencyExpr,
|
||||
can1.price,
|
||||
payment.locale.coalesce(""),
|
||||
formattedDate
|
||||
)
|
||||
)
|
||||
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(whereBuilder)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||
chargeStatusList.addAll(0, summaryRows)
|
||||
var totalChargeAmount = 0
|
||||
var totalChargeCount = 0L
|
||||
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||
.asSequence()
|
||||
.map {
|
||||
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
||||
it.pgChargeAmount
|
||||
} else {
|
||||
it.appleChargeAmount.toInt()
|
||||
}
|
||||
|
||||
val chargeCount = it.chargeCount
|
||||
|
||||
totalChargeAmount += chargeAmount
|
||||
totalChargeCount += chargeCount
|
||||
|
||||
GetChargeStatusResponse(
|
||||
date = it.date,
|
||||
chargeAmount = chargeAmount,
|
||||
chargeCount = chargeCount,
|
||||
pg = it.paymentGateWay.name
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
chargeStatusList.add(
|
||||
0,
|
||||
GetChargeStatusResponse(
|
||||
date = "합계",
|
||||
chargeAmount = totalChargeAmount,
|
||||
chargeCount = totalChargeCount,
|
||||
pg = ""
|
||||
)
|
||||
)
|
||||
|
||||
return chargeStatusList.toList()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDateStr: String,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailResponse> {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||
.asSequence()
|
||||
.map {
|
||||
GetChargeStatusDetailResponse(
|
||||
memberId = it.memberId,
|
||||
nickname = it.nickname,
|
||||
method = it.method,
|
||||
amount = it.amount,
|
||||
amount = it.appleChargeAmount.toInt(),
|
||||
locale = it.locale,
|
||||
datetime = it.datetime
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailResponse(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val amount: Int,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
|
||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||
val date: String,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val paymentGateWay: PaymentGateway
|
||||
)
|
||||
@@ -1,12 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||
data class GetChargeStatusResponse(
|
||||
val date: String,
|
||||
val chargeAmount: BigDecimal,
|
||||
val chargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val pg: String,
|
||||
val currency: String
|
||||
val pg: String
|
||||
)
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/banner")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminChatBannerController(
|
||||
private val bannerService: ChatCharacterBannerService,
|
||||
private val adminCharacterService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val langContext: LangContext,
|
||||
private val messageSource: SodaMessageSource,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 배너 목록 조회 API
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 페이징된 배너 목록
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getBannerList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = ChatCharacterBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) }
|
||||
)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 상세 조회 API
|
||||
*
|
||||
* @param bannerId 배너 ID
|
||||
* @return 배너 상세 정보
|
||||
*/
|
||||
@GetMapping("/{bannerId}")
|
||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||
val banner = bannerService.getBannerById(bannerId)
|
||||
val response = ChatCharacterBannerResponse.from(banner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 API (배너 등록을 위한)
|
||||
*
|
||||
* @param searchTerm 검색어 (이름, 설명, MBTI, 태그)
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 검색된 캐릭터 목록
|
||||
*/
|
||||
@GetMapping("/search-character")
|
||||
fun searchCharacters(
|
||||
@RequestParam searchTerm: String,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminCharacterService.createDefaultPageRequest(page, size)
|
||||
val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost)
|
||||
val response = ChatCharacterSearchListPageResponse(
|
||||
totalCount = pageResult.totalElements,
|
||||
content = pageResult.content
|
||||
)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 등록 API
|
||||
*
|
||||
* @param image 배너 이미지
|
||||
* @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함)
|
||||
* @return 등록된 배너 정보
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun registerBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(
|
||||
requestString,
|
||||
ChatCharacterBannerRegisterRequest::class.java
|
||||
)
|
||||
|
||||
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
|
||||
val banner = bannerService.registerBanner(
|
||||
characterId = request.characterId,
|
||||
imagePath = ""
|
||||
)
|
||||
|
||||
// 2. 배너 ID를 사용하여 이미지 업로드
|
||||
val imagePath = saveImage(banner.id!!, image)
|
||||
|
||||
// 3. 이미지 경로로 배너 업데이트
|
||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||
|
||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 S3에 업로드하고 경로를 반환
|
||||
*
|
||||
* @param bannerId 배너 ID (이미지 경로에 사용)
|
||||
* @param image 업로드할 이미지 파일
|
||||
* @return 업로드된 이미지 경로
|
||||
*/
|
||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
|
||||
val fileName = generateFileName("character-banner")
|
||||
|
||||
// S3에 이미지 업로드 (배너 ID를 경로에 사용)
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "characters/banners/$bannerId/$fileName",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
throw SodaException(messageKey = "admin.chat.banner.image_save_failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정 API
|
||||
*
|
||||
* @param image 배너 이미지
|
||||
* @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함)
|
||||
* @return 수정된 배너 정보
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(
|
||||
requestString,
|
||||
ChatCharacterBannerUpdateRequest::class.java
|
||||
)
|
||||
// 배너 정보 조회
|
||||
bannerService.getBannerById(request.bannerId)
|
||||
|
||||
// 배너 ID를 사용하여 이미지 업로드
|
||||
val imagePath = saveImage(request.bannerId, image)
|
||||
|
||||
// 배너 수정 (이미지와 캐릭터 모두 수정 가능)
|
||||
val updatedBanner = bannerService.updateBanner(
|
||||
bannerId = request.bannerId,
|
||||
imagePath = imagePath,
|
||||
characterId = request.characterId
|
||||
)
|
||||
|
||||
val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 삭제 API (소프트 삭제)
|
||||
*
|
||||
* @param bannerId 배너 ID
|
||||
* @return 성공 여부
|
||||
*/
|
||||
@DeleteMapping("/{bannerId}")
|
||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||
bannerService.deleteBanner(bannerId)
|
||||
|
||||
val message = messageSource.getMessage("admin.chat.banner.delete_success", langContext.lang)
|
||||
ApiResponse.ok(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경 API
|
||||
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||
*
|
||||
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
||||
* @return 성공 메시지
|
||||
*/
|
||||
@PutMapping("/orders")
|
||||
fun updateBannerOrders(
|
||||
@RequestBody request: UpdateBannerOrdersRequest
|
||||
) = run {
|
||||
bannerService.updateBannerOrders(request.ids)
|
||||
|
||||
val message = messageSource.getMessage("admin.chat.banner.reorder_success", langContext.lang)
|
||||
ApiResponse.ok(null, message)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/chat/calculate")
|
||||
class AdminChatCalculateController(
|
||||
private val service: AdminChatCalculateService
|
||||
) {
|
||||
@GetMapping("/characters")
|
||||
fun getCharacterCalculate(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
@RequestParam(required = false, defaultValue = "TOTAL_SALES_DESC") sort: ChatCharacterCalculateSort,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCharacterCalculate(
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
sort,
|
||||
pageable.offset,
|
||||
pageable.pageSize
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||
import kr.co.vividnext.sodalive.chat.character.QChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.image.QCharacterImage.characterImage
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminChatCalculateQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
fun getCharacterCalculate(
|
||||
startUtc: LocalDateTime,
|
||||
endInclusiveUtc: LocalDateTime,
|
||||
sort: ChatCharacterCalculateSort,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<ChatCharacterCalculateQueryData> {
|
||||
val imageCanExpr = CaseBuilder()
|
||||
.`when`(useCan.canUsage.eq(CanUsage.CHARACTER_IMAGE_PURCHASE))
|
||||
.then(useCan.can.add(useCan.rewardCan))
|
||||
.otherwise(0)
|
||||
|
||||
val messageCanExpr = CaseBuilder()
|
||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_MESSAGE_PURCHASE))
|
||||
.then(useCan.can.add(useCan.rewardCan))
|
||||
.otherwise(0)
|
||||
|
||||
val quotaCanExpr = CaseBuilder()
|
||||
.`when`(useCan.canUsage.eq(CanUsage.CHAT_QUOTA_PURCHASE))
|
||||
.then(useCan.can.add(useCan.rewardCan))
|
||||
.otherwise(0)
|
||||
|
||||
val imageSum = imageCanExpr.sum()
|
||||
val messageSum = messageCanExpr.sum()
|
||||
val quotaSum = quotaCanExpr.sum()
|
||||
val totalSum = imageSum.add(messageSum).add(quotaSum)
|
||||
|
||||
// 캐릭터 조인: 이미지 경로를 통한 캐릭터(c1) + characterId 직접 지정(c2)
|
||||
val c1 = QChatCharacter("c1")
|
||||
val c2 = QChatCharacter("c2")
|
||||
|
||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||
val characterNameAgg = Expressions.stringTemplate(
|
||||
"coalesce(max({0}), max({1}), '')",
|
||||
c1.name,
|
||||
c2.name
|
||||
)
|
||||
val characterImagePathAgg = Expressions.stringTemplate(
|
||||
"coalesce(max({0}), max({1}))",
|
||||
c1.imagePath,
|
||||
c2.imagePath
|
||||
)
|
||||
|
||||
val query = queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
ChatCharacterCalculateQueryData::class.java,
|
||||
characterIdExpr,
|
||||
characterNameAgg,
|
||||
characterImagePathAgg.prepend("/").prepend(imageHost),
|
||||
imageSum,
|
||||
messageSum,
|
||||
quotaSum
|
||||
)
|
||||
)
|
||||
.from(useCan)
|
||||
.leftJoin(useCan.characterImage, characterImage)
|
||||
.leftJoin(characterImage.chatCharacter, c1)
|
||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(
|
||||
useCan.canUsage.`in`(
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
CanUsage.CHAT_QUOTA_PURCHASE
|
||||
)
|
||||
)
|
||||
.and(useCan.createdAt.goe(startUtc))
|
||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||
)
|
||||
.groupBy(characterIdExpr)
|
||||
|
||||
when (sort) {
|
||||
ChatCharacterCalculateSort.TOTAL_SALES_DESC ->
|
||||
query.orderBy(totalSum.desc(), characterIdExpr.desc())
|
||||
|
||||
ChatCharacterCalculateSort.LATEST_DESC ->
|
||||
query.orderBy(characterIdExpr.desc(), totalSum.desc())
|
||||
}
|
||||
|
||||
return query
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCharacterCalculateTotalCount(
|
||||
startUtc: LocalDateTime,
|
||||
endInclusiveUtc: LocalDateTime
|
||||
): Int {
|
||||
val c1 = QChatCharacter("c1")
|
||||
val c2 = QChatCharacter("c2")
|
||||
val characterIdExpr = c1.id.coalesce(c2.id)
|
||||
|
||||
return queryFactory
|
||||
.select(characterIdExpr)
|
||||
.from(useCan)
|
||||
.leftJoin(useCan.characterImage, characterImage)
|
||||
.leftJoin(characterImage.chatCharacter, c1)
|
||||
.leftJoin(c2).on(c2.id.eq(useCan.characterId))
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(
|
||||
useCan.canUsage.`in`(
|
||||
CanUsage.CHARACTER_IMAGE_PURCHASE,
|
||||
CanUsage.CHAT_MESSAGE_PURCHASE,
|
||||
CanUsage.CHAT_QUOTA_PURCHASE
|
||||
)
|
||||
)
|
||||
.and(useCan.createdAt.goe(startUtc))
|
||||
.and(useCan.createdAt.loe(endInclusiveUtc))
|
||||
)
|
||||
.groupBy(characterIdExpr)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class AdminChatCalculateService(
|
||||
private val repository: AdminChatCalculateQueryRepository
|
||||
) {
|
||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
private val kstZone: ZoneId = ZoneId.of("Asia/Seoul")
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharacterCalculate(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
sort: ChatCharacterCalculateSort,
|
||||
offset: Long,
|
||||
pageSize: Int
|
||||
): ChatCharacterCalculateResponse {
|
||||
// 날짜 유효성 검증 (KST 기준)
|
||||
val startDate = LocalDate.parse(startDateStr, dateFormatter)
|
||||
val endDate = LocalDate.parse(endDateStr, dateFormatter)
|
||||
val todayKst = LocalDate.now(kstZone)
|
||||
|
||||
if (endDate.isAfter(todayKst)) {
|
||||
throw SodaException(messageKey = "admin.chat.calculate.end_date_max_today")
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
throw SodaException(messageKey = "admin.chat.calculate.start_date_after_end")
|
||||
}
|
||||
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||
throw SodaException(messageKey = "admin.chat.calculate.max_period_6_months")
|
||||
}
|
||||
|
||||
val startUtc = startDateStr.convertLocalDateTime()
|
||||
val endInclusiveUtc = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
|
||||
val totalCount = repository.getCharacterCalculateTotalCount(startUtc, endInclusiveUtc)
|
||||
val rows = repository.getCharacterCalculate(startUtc, endInclusiveUtc, sort, offset, pageSize.toLong())
|
||||
val items = rows.map { it.toItem() }
|
||||
return ChatCharacterCalculateResponse(totalCount, items)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.calculate
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
// 정렬 옵션
|
||||
enum class ChatCharacterCalculateSort {
|
||||
TOTAL_SALES_DESC,
|
||||
LATEST_DESC
|
||||
}
|
||||
|
||||
// QueryDSL 프로젝션용 DTO
|
||||
data class ChatCharacterCalculateQueryData @QueryProjection constructor(
|
||||
val characterId: Long,
|
||||
val characterName: String,
|
||||
val characterImagePath: String?,
|
||||
val imagePurchaseCan: Int?,
|
||||
val messagePurchaseCan: Int?,
|
||||
val quotaPurchaseCan: Int?
|
||||
)
|
||||
|
||||
// 응답 DTO (아이템)
|
||||
data class ChatCharacterCalculateItem(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("characterImage") val characterImage: String?,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("imagePurchaseCan") val imagePurchaseCan: Int,
|
||||
@JsonProperty("messagePurchaseCan") val messagePurchaseCan: Int,
|
||||
@JsonProperty("quotaPurchaseCan") val quotaPurchaseCan: Int,
|
||||
@JsonProperty("totalCan") val totalCan: Int,
|
||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||
@JsonProperty("settlementKrw") val settlementKrw: Int
|
||||
)
|
||||
|
||||
// 응답 DTO (전체)
|
||||
data class ChatCharacterCalculateResponse(
|
||||
@JsonProperty("totalCount") val totalCount: Int,
|
||||
@JsonProperty("items") val items: List<ChatCharacterCalculateItem>
|
||||
)
|
||||
|
||||
fun ChatCharacterCalculateQueryData.toItem(): ChatCharacterCalculateItem {
|
||||
val image = imagePurchaseCan ?: 0
|
||||
val message = messagePurchaseCan ?: 0
|
||||
val quota = quotaPurchaseCan ?: 0
|
||||
val total = image + message + quota
|
||||
val totalKrw = BigDecimal(total).multiply(BigDecimal(100))
|
||||
val settlement = totalKrw.multiply(BigDecimal("0.10")).setScale(0, RoundingMode.HALF_UP)
|
||||
|
||||
return ChatCharacterCalculateItem(
|
||||
characterId = characterId,
|
||||
characterImage = characterImagePath,
|
||||
name = characterName,
|
||||
imagePurchaseCan = image,
|
||||
messagePurchaseCan = message,
|
||||
quotaPurchaseCan = quota,
|
||||
totalCan = total,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
settlementKrw = settlement.toInt()
|
||||
)
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.client.RestTemplate
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminChatCharacterController(
|
||||
private val service: ChatCharacterService,
|
||||
private val adminService: AdminChatCharacterService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${weraser.api-key}")
|
||||
private val apiKey: String,
|
||||
|
||||
@Value("\${weraser.api-url}")
|
||||
private val apiUrl: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록 조회 API
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값 0)
|
||||
* @param size 페이지 크기 (기본값 20)
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getCharacterList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||
val response = adminService.getActiveChatCharacters(pageable, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색(관리자)
|
||||
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
|
||||
* - 페이징 지원: page, size 파라미터 사용
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
fun searchCharacters(
|
||||
@RequestParam("searchTerm") searchTerm: String,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = adminService.createDefaultPageRequest(page, size)
|
||||
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
|
||||
val response = ChatCharacterSearchListPageResponse(
|
||||
totalCount = resultPage.totalElements,
|
||||
content = resultPage.content
|
||||
)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회 API
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @return 캐릭터 상세 정보
|
||||
*/
|
||||
@GetMapping("/{characterId}")
|
||||
fun getCharacterDetail(
|
||||
@PathVariable characterId: Long
|
||||
) = run {
|
||||
val response = adminService.getChatCharacterDetail(characterId, imageHost)
|
||||
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun registerCharacter(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
|
||||
|
||||
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
|
||||
// 1. 외부 API 호출
|
||||
val characterUUID = callExternalApi(request)
|
||||
|
||||
// 2. ChatCharacter 저장
|
||||
val chatCharacter = service.createChatCharacterWithDetails(
|
||||
characterUUID = characterUUID,
|
||||
name = request.name,
|
||||
description = request.description,
|
||||
systemPrompt = request.systemPrompt,
|
||||
age = request.age?.toIntOrNull(),
|
||||
gender = request.gender,
|
||||
mbti = request.mbti,
|
||||
speechPattern = request.speechPattern,
|
||||
speechStyle = request.speechStyle,
|
||||
appearance = request.appearance,
|
||||
originalTitle = request.originalTitle,
|
||||
originalLink = request.originalLink,
|
||||
characterType = request.characterType?.let {
|
||||
runCatching { CharacterType.valueOf(it) }
|
||||
.getOrDefault(CharacterType.Character)
|
||||
} ?: CharacterType.Character,
|
||||
tags = request.tags,
|
||||
values = request.values,
|
||||
hobbies = request.hobbies,
|
||||
goals = request.goals,
|
||||
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
|
||||
personalities = request.personalities.map { Pair(it.trait, it.description) },
|
||||
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
|
||||
relationships = request.relationships
|
||||
)
|
||||
|
||||
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
|
||||
val imagePath = saveImage(
|
||||
characterId = chatCharacter.id!!,
|
||||
image = image
|
||||
)
|
||||
chatCharacter.imagePath = imagePath
|
||||
service.saveChatCharacter(chatCharacter)
|
||||
|
||||
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
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)
|
||||
}
|
||||
|
||||
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
|
||||
val body = mutableMapOf<String, Any>()
|
||||
body["name"] = request.name
|
||||
body["systemPrompt"] = request.systemPrompt
|
||||
body["description"] = request.description
|
||||
request.age?.let { body["age"] = it }
|
||||
request.gender?.let { body["gender"] = it }
|
||||
request.mbti?.let { body["mbti"] = it }
|
||||
request.speechPattern?.let { body["speechPattern"] = it }
|
||||
request.speechStyle?.let { body["speechStyle"] = it }
|
||||
request.appearance?.let { body["appearance"] = it }
|
||||
if (request.tags.isNotEmpty()) body["tags"] = request.tags
|
||||
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
|
||||
if (request.values.isNotEmpty()) body["values"] = request.values
|
||||
if (request.goals.isNotEmpty()) body["goals"] = request.goals
|
||||
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
|
||||
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
|
||||
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
|
||||
if (request.memories.isNotEmpty()) body["memories"] = request.memories
|
||||
|
||||
val httpEntity = HttpEntity(body, headers)
|
||||
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters",
|
||||
HttpMethod.POST,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
val apiMessage = apiResponse.message
|
||||
if (apiMessage.isNullOrBlank()) {
|
||||
throw SodaException(messageKey = "admin.chat.character.register_failed_retry")
|
||||
}
|
||||
throw SodaException(apiMessage)
|
||||
}
|
||||
|
||||
// success가 true이면 data.id 반환
|
||||
return apiResponse.data?.id ?: throw SodaException(messageKey = "admin.chat.character.register_failed_no_id")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException(
|
||||
message = e.message,
|
||||
messageKey = "admin.chat.character.register_failed_retry"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(characterId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
|
||||
// S3에 이미지 업로드
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException(
|
||||
message = e.message,
|
||||
messageKey = "admin.chat.character.image_save_failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 수정 API
|
||||
* 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
* 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
* 3. 이미지 있는지 확인
|
||||
* 4. 2, 3번 중 하나라도 해당 하면 계속 진행
|
||||
* 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.')
|
||||
*
|
||||
* @param image 캐릭터 이미지 (선택적)
|
||||
* @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값
|
||||
* @return ApiResponse 객체
|
||||
* @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateCharacter(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
// 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
|
||||
|
||||
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
|
||||
val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
|
||||
|
||||
// 3. 이미지 있는지 확인
|
||||
val hasImage = image != null && !image.isEmpty
|
||||
|
||||
// 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
|
||||
val hasDbOnlyChanges =
|
||||
request.originalTitle != null ||
|
||||
request.originalLink != null ||
|
||||
request.characterType != null ||
|
||||
request.originalWorkId != null
|
||||
|
||||
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
|
||||
throw SodaException(messageKey = "admin.chat.character.no_changes")
|
||||
}
|
||||
|
||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||
if (hasChangedData) {
|
||||
val chatCharacter = service.findById(request.id)
|
||||
?: throw SodaException(
|
||||
message = "해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}",
|
||||
messageKey = "admin.chat.character.not_found"
|
||||
)
|
||||
|
||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||
if (request.name != null && request.name != chatCharacter.name) {
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException(
|
||||
message = "동일한 이름은 등록이 불가능합니다: ${request.name}",
|
||||
messageKey = "admin.chat.character.duplicate_name"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||
}
|
||||
|
||||
// 이미지 경로 변수 초기화
|
||||
// 이미지가 있으면 이미지 저장
|
||||
val imagePath = if (hasImage) {
|
||||
saveImage(
|
||||
characterId = request.id,
|
||||
image = image!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 엔티티 수정
|
||||
service.updateChatCharacterWithDetails(
|
||||
imagePath = imagePath,
|
||||
request = request
|
||||
)
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = request.id,
|
||||
targetType = LanguageTranslationTargetType.CHARACTER,
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
|
||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||
if (request.originalWorkId != null) {
|
||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청에 변경된 데이터가 있는지 확인
|
||||
* id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단
|
||||
*
|
||||
* @param request 수정 요청 데이터
|
||||
* @return 변경된 데이터가 있으면 true, 없으면 false
|
||||
*/
|
||||
private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean {
|
||||
return request.systemPrompt != null ||
|
||||
request.description != null ||
|
||||
request.age != null ||
|
||||
request.gender != null ||
|
||||
request.mbti != null ||
|
||||
request.speechPattern != null ||
|
||||
request.speechStyle != null ||
|
||||
request.appearance != null ||
|
||||
request.isActive != null ||
|
||||
request.tags != null ||
|
||||
request.hobbies != null ||
|
||||
request.values != null ||
|
||||
request.goals != null ||
|
||||
request.relationships != null ||
|
||||
request.personalities != null ||
|
||||
request.backgrounds != null ||
|
||||
request.memories != null ||
|
||||
request.name != null
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 호출 - 수정 API
|
||||
* 변경된 데이터만 요청에 포함
|
||||
*
|
||||
* @param characterUUID 캐릭터 UUID
|
||||
* @param request 수정 요청 데이터
|
||||
*/
|
||||
private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) {
|
||||
try {
|
||||
val factory = SimpleClientHttpRequestFactory()
|
||||
factory.setConnectTimeout(20000) // 20초
|
||||
factory.setReadTimeout(20000) // 20초
|
||||
|
||||
val restTemplate = RestTemplate(factory)
|
||||
|
||||
val headers = HttpHeaders()
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.contentType = MediaType.APPLICATION_JSON
|
||||
|
||||
// 변경된 데이터만 포함하는 맵 생성
|
||||
val updateData = mutableMapOf<String, Any>()
|
||||
|
||||
// isActive = false인 경우 처리
|
||||
if (request.isActive != null && !request.isActive) {
|
||||
val inactiveName = "inactive_${request.name}"
|
||||
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
|
||||
updateData["name"] = inactiveName + randomSuffix
|
||||
} else {
|
||||
request.name?.let { updateData["name"] = it }
|
||||
request.systemPrompt?.let { updateData["systemPrompt"] = it }
|
||||
request.description?.let { updateData["description"] = it }
|
||||
request.age?.let { updateData["age"] = it }
|
||||
request.gender?.let { updateData["gender"] = it }
|
||||
request.mbti?.let { updateData["mbti"] = it }
|
||||
request.speechPattern?.let { updateData["speechPattern"] = it }
|
||||
request.speechStyle?.let { updateData["speechStyle"] = it }
|
||||
request.appearance?.let { updateData["appearance"] = it }
|
||||
request.tags?.let { updateData["tags"] = it }
|
||||
request.hobbies?.let { updateData["hobbies"] = it }
|
||||
request.values?.let { updateData["values"] = it }
|
||||
request.goals?.let { updateData["goals"] = it }
|
||||
request.relationships?.let { updateData["relationships"] = it }
|
||||
request.personalities?.let { updateData["personalities"] = it }
|
||||
request.backgrounds?.let { updateData["backgrounds"] = it }
|
||||
request.memories?.let { updateData["memories"] = it }
|
||||
}
|
||||
|
||||
val httpEntity = HttpEntity(updateData, headers)
|
||||
val response = restTemplate.exchange(
|
||||
"$apiUrl/api/characters/$characterUUID",
|
||||
HttpMethod.PUT,
|
||||
httpEntity,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
// 응답 파싱
|
||||
val objectMapper = ObjectMapper()
|
||||
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
|
||||
|
||||
// success가 false이면 throw
|
||||
if (!apiResponse.success) {
|
||||
val apiMessage = apiResponse.message
|
||||
if (apiMessage.isNullOrBlank()) {
|
||||
throw SodaException(messageKey = "admin.chat.character.update_failed_retry")
|
||||
}
|
||||
throw SodaException(apiMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException(
|
||||
message = e.message,
|
||||
messageKey = "admin.chat.character.update_failed_retry"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/curation")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class CharacterCurationAdminController(
|
||||
private val service: CharacterCurationAdminService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@GetMapping("/list")
|
||||
fun listAll(): ApiResponse<List<CharacterCurationListItemResponse>> =
|
||||
ApiResponse.ok(service.listAll())
|
||||
|
||||
@GetMapping("/{curationId}/characters")
|
||||
fun listCharacters(
|
||||
@PathVariable curationId: Long
|
||||
): ApiResponse<List<CharacterCurationCharacterItemResponse>> {
|
||||
val characters = service.listCharacters(curationId)
|
||||
val items = characters.map {
|
||||
CharacterCurationCharacterItemResponse(
|
||||
id = it.id!!,
|
||||
name = it.name,
|
||||
description = it.description,
|
||||
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}"
|
||||
)
|
||||
}
|
||||
return ApiResponse.ok(items)
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(@RequestBody request: CharacterCurationRegisterRequest) =
|
||||
ApiResponse.ok(service.register(request).id)
|
||||
|
||||
@PutMapping("/update")
|
||||
fun update(@RequestBody request: CharacterCurationUpdateRequest) =
|
||||
ApiResponse.ok(service.update(request).id)
|
||||
|
||||
@DeleteMapping("/{curationId}")
|
||||
fun delete(@PathVariable curationId: Long) =
|
||||
ApiResponse.ok(service.softDelete(curationId))
|
||||
|
||||
@PutMapping("/reorder")
|
||||
fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) =
|
||||
ApiResponse.ok(service.reorder(request.ids))
|
||||
|
||||
@PostMapping("/{curationId}/characters")
|
||||
fun addCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationAddCharacterRequest
|
||||
): ApiResponse<Boolean> {
|
||||
val ids = request.characterIds.filter { it > 0 }.distinct()
|
||||
if (ids.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty")
|
||||
service.addCharacters(curationId, ids)
|
||||
return ApiResponse.ok(true)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{curationId}/characters/{characterId}")
|
||||
fun removeCharacter(
|
||||
@PathVariable curationId: Long,
|
||||
@PathVariable characterId: Long
|
||||
) = ApiResponse.ok(service.removeCharacter(curationId, characterId))
|
||||
|
||||
@PutMapping("/{curationId}/characters/reorder")
|
||||
fun reorderCharacters(
|
||||
@PathVariable curationId: Long,
|
||||
@RequestBody request: CharacterCurationReorderCharactersRequest
|
||||
) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
data class CharacterCurationRegisterRequest(
|
||||
val title: String,
|
||||
val isAdult: Boolean = false,
|
||||
val isActive: Boolean = true
|
||||
)
|
||||
|
||||
data class CharacterCurationUpdateRequest(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isActive: Boolean? = null
|
||||
)
|
||||
|
||||
data class CharacterCurationOrderUpdateRequest(
|
||||
val ids: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationAddCharacterRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationReorderCharactersRequest(
|
||||
val characterIds: List<Long>
|
||||
)
|
||||
|
||||
data class CharacterCurationListItemResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val isAdult: Boolean,
|
||||
val isActive: Boolean,
|
||||
val characterCount: Int
|
||||
)
|
||||
|
||||
// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO
|
||||
// id, name, description, 이미지 URL
|
||||
// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성
|
||||
|
||||
data class CharacterCurationCharacterItemResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val imageUrl: String
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CharacterCurationAdminService(
|
||||
private val curationRepository: CharacterCurationRepository,
|
||||
private val mappingRepository: CharacterCurationMappingRepository,
|
||||
private val characterRepository: ChatCharacterRepository
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun register(request: CharacterCurationRegisterRequest): CharacterCuration {
|
||||
val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1
|
||||
val curation = CharacterCuration(
|
||||
title = request.title,
|
||||
isAdult = request.isAdult,
|
||||
isActive = request.isActive,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun update(request: CharacterCurationUpdateRequest): CharacterCuration {
|
||||
val curation = curationRepository.findById(request.id)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
|
||||
request.title?.let { curation.title = it }
|
||||
request.isAdult?.let { curation.isAdult = it }
|
||||
request.isActive?.let { curation.isActive = it }
|
||||
|
||||
return curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun softDelete(curationId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
curation.isActive = false
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorder(ids: List<Long>) {
|
||||
ids.forEachIndexed { index, id ->
|
||||
val curation = curationRepository.findById(id)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
curation.sortOrder = index + 1
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
if (characterIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.character_ids_empty")
|
||||
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
if (!curation.isActive) throw SodaException(messageKey = "admin.chat.curation.inactive")
|
||||
|
||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||
if (uniqueIds.isEmpty()) throw SodaException(messageKey = "admin.chat.curation.invalid_character_ids")
|
||||
|
||||
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||
val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds)
|
||||
val characterMap = characters.associateBy { it.id!! }
|
||||
|
||||
// 조회 결과에 존재하는 캐릭터만 유효
|
||||
val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) }
|
||||
|
||||
val existingMappings = mappingRepository.findByCuration(curation)
|
||||
val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet()
|
||||
var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1
|
||||
|
||||
val toSave = mutableListOf<CharacterCurationMapping>()
|
||||
validIds.forEach { id ->
|
||||
if (!existingCharacterIds.contains(id)) {
|
||||
val character = characterMap[id] ?: return@forEach
|
||||
toSave += CharacterCurationMapping(
|
||||
curation = curation,
|
||||
chatCharacter = character,
|
||||
sortOrder = nextOrder++
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.isNotEmpty()) {
|
||||
mappingRepository.saveAll(toSave)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun removeCharacter(curationId: Long, characterId: Long) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||
?: throw SodaException(messageKey = "admin.chat.curation.mapping_not_found")
|
||||
mappingRepository.delete(target)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||
|
||||
characterIds.forEachIndexed { index, cid ->
|
||||
val mapping = mappingByCharacterId[cid]
|
||||
?: throw SodaException(messageKey = "admin.chat.curation.character_not_in_curation")
|
||||
mapping.sortOrder = index + 1
|
||||
mappingRepository.save(mapping)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listAll(): List<CharacterCurationListItemResponse> {
|
||||
val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc()
|
||||
if (curations.isEmpty()) return emptyList()
|
||||
|
||||
// DB 집계로 활성 캐릭터 수 카운트
|
||||
val counts = mappingRepository.countActiveCharactersByCurations(curations)
|
||||
val countByCurationId: Map<Long, Int> = counts.associate { it.curationId to it.count.toInt() }
|
||||
|
||||
return curations.map { curation ->
|
||||
CharacterCurationListItemResponse(
|
||||
id = curation.id!!,
|
||||
title = curation.title,
|
||||
isAdult = curation.isAdult,
|
||||
isActive = curation.isActive,
|
||||
characterCount = countByCurationId[curation.id!!] ?: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun listCharacters(curationId: Long): List<ChatCharacter> {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.curation.not_found") }
|
||||
val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation)
|
||||
return mappings.map { it.chatCharacter }
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 관리자 캐릭터 상세 응답 DTO
|
||||
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다.
|
||||
*/
|
||||
data class ChatCharacterDetailResponse(
|
||||
val id: Long,
|
||||
val characterUUID: String,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val systemPrompt: String,
|
||||
val characterType: String,
|
||||
val age: Int?,
|
||||
val gender: String?,
|
||||
val mbti: String?,
|
||||
val speechPattern: String?,
|
||||
val speechStyle: String?,
|
||||
val appearance: String?,
|
||||
val isActive: Boolean,
|
||||
val tags: List<String>,
|
||||
val hobbies: List<String>,
|
||||
val values: List<String>,
|
||||
val goals: List<String>,
|
||||
val relationships: List<RelationshipResponse>,
|
||||
val personalities: List<PersonalityResponse>,
|
||||
val backgrounds: List<BackgroundResponse>,
|
||||
val memories: List<MemoryResponse>,
|
||||
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
|
||||
) {
|
||||
companion object {
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
chatCharacter.imagePath ?: ""
|
||||
}
|
||||
|
||||
val ow = chatCharacter.originalWork
|
||||
val originalWorkBrief = ow?.let {
|
||||
val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${it.imagePath}"
|
||||
} else {
|
||||
it.imagePath
|
||||
}
|
||||
OriginalWorkBriefResponse(
|
||||
id = it.id!!,
|
||||
imageUrl = owImage,
|
||||
title = it.title
|
||||
)
|
||||
}
|
||||
|
||||
return ChatCharacterDetailResponse(
|
||||
id = chatCharacter.id!!,
|
||||
characterUUID = chatCharacter.characterUUID,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
systemPrompt = chatCharacter.systemPrompt,
|
||||
characterType = chatCharacter.characterType.name,
|
||||
age = chatCharacter.age,
|
||||
gender = chatCharacter.gender,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
appearance = chatCharacter.appearance,
|
||||
isActive = chatCharacter.isActive,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
|
||||
values = chatCharacter.valueMappings.map { it.value.value },
|
||||
goals = chatCharacter.goalMappings.map { it.goal.goal },
|
||||
relationships = chatCharacter.relationships.map {
|
||||
RelationshipResponse(
|
||||
personName = it.personName,
|
||||
relationshipName = it.relationshipName,
|
||||
description = it.description,
|
||||
importance = it.importance,
|
||||
relationshipType = it.relationshipType,
|
||||
currentStatus = it.currentStatus
|
||||
)
|
||||
},
|
||||
personalities = chatCharacter.personalities.map {
|
||||
PersonalityResponse(it.trait, it.description)
|
||||
},
|
||||
backgrounds = chatCharacter.backgrounds.map {
|
||||
BackgroundResponse(it.topic, it.description)
|
||||
},
|
||||
memories = chatCharacter.memories.map {
|
||||
MemoryResponse(it.title, it.content, it.emotion)
|
||||
},
|
||||
originalWork = originalWorkBrief
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PersonalityResponse(
|
||||
val trait: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class BackgroundResponse(
|
||||
val topic: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
data class MemoryResponse(
|
||||
val title: String,
|
||||
val content: String,
|
||||
val emotion: String
|
||||
)
|
||||
|
||||
data class RelationshipResponse(
|
||||
val personName: String,
|
||||
val relationshipName: String,
|
||||
val description: String,
|
||||
val importance: Int,
|
||||
val relationshipType: String,
|
||||
val currentStatus: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
|
||||
*/
|
||||
data class OriginalWorkBriefResponse(
|
||||
val id: Long,
|
||||
val imageUrl: String?,
|
||||
val title: String
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class ChatCharacterPersonalityRequest(
|
||||
@JsonProperty("trait") val trait: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterBackgroundRequest(
|
||||
@JsonProperty("topic") val topic: String,
|
||||
@JsonProperty("description") val description: String
|
||||
)
|
||||
|
||||
data class ChatCharacterMemoryRequest(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("content") val content: String,
|
||||
@JsonProperty("emotion") val emotion: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRelationshipRequest(
|
||||
@JsonProperty("personName") val personName: String,
|
||||
@JsonProperty("relationshipName") val relationshipName: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("importance") val importance: Int,
|
||||
@JsonProperty("relationshipType") val relationshipType: String,
|
||||
@JsonProperty("currentStatus") val currentStatus: String
|
||||
)
|
||||
|
||||
data class ChatCharacterRegisterRequest(
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String,
|
||||
@JsonProperty("description") val description: String,
|
||||
@JsonProperty("age") val age: String?,
|
||||
@JsonProperty("gender") val gender: String?,
|
||||
@JsonProperty("mbti") val mbti: String?,
|
||||
@JsonProperty("speechPattern") val speechPattern: String?,
|
||||
@JsonProperty("speechStyle") val speechStyle: String?,
|
||||
@JsonProperty("appearance") val appearance: String?,
|
||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("tags") val tags: List<String> = emptyList(),
|
||||
@JsonProperty("hobbies") val hobbies: List<String> = emptyList(),
|
||||
@JsonProperty("values") val values: List<String> = emptyList(),
|
||||
@JsonProperty("goals") val goals: List<String> = emptyList(),
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
|
||||
)
|
||||
|
||||
data class ExternalApiResponse(
|
||||
@JsonProperty("success") val success: Boolean,
|
||||
@JsonProperty("data") val data: ExternalApiData? = null,
|
||||
@JsonProperty("message") val message: String? = null
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ExternalApiData(
|
||||
@JsonProperty("id") val id: String
|
||||
)
|
||||
|
||||
data class ChatCharacterUpdateRequest(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("name") val name: String? = null,
|
||||
@JsonProperty("systemPrompt") val systemPrompt: String? = null,
|
||||
@JsonProperty("description") val description: String? = null,
|
||||
@JsonProperty("age") val age: String? = null,
|
||||
@JsonProperty("gender") val gender: String? = null,
|
||||
@JsonProperty("mbti") val mbti: String? = null,
|
||||
@JsonProperty("speechPattern") val speechPattern: String? = null,
|
||||
@JsonProperty("speechStyle") val speechStyle: String? = null,
|
||||
@JsonProperty("appearance") val appearance: String? = null,
|
||||
@JsonProperty("originalTitle") val originalTitle: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("originalWorkId") val originalWorkId: Long? = null,
|
||||
@JsonProperty("characterType") val characterType: String? = null,
|
||||
@JsonProperty("isActive") val isActive: Boolean? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null,
|
||||
@JsonProperty("hobbies") val hobbies: List<String>? = null,
|
||||
@JsonProperty("values") val values: List<String>? = null,
|
||||
@JsonProperty("goals") val goals: List<String>? = null,
|
||||
@JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
|
||||
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
|
||||
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
|
||||
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class ChatCharacterListResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imageUrl: String?,
|
||||
val description: String,
|
||||
val gender: String?,
|
||||
val age: Int?,
|
||||
val mbti: String?,
|
||||
val speechStyle: String?,
|
||||
val speechPattern: String?,
|
||||
val tags: List<String>,
|
||||
val createdAt: String?,
|
||||
val updatedAt: String?
|
||||
) {
|
||||
companion object {
|
||||
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
private val seoulZoneId = ZoneId.of("Asia/Seoul")
|
||||
|
||||
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
|
||||
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${chatCharacter.imagePath}"
|
||||
} else {
|
||||
chatCharacter.imagePath
|
||||
}
|
||||
|
||||
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
|
||||
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
|
||||
?.withZoneSameInstant(seoulZoneId)
|
||||
?.format(formatter)
|
||||
|
||||
return ChatCharacterListResponse(
|
||||
id = chatCharacter.id!!,
|
||||
name = chatCharacter.name,
|
||||
imageUrl = fullImagePath,
|
||||
description = chatCharacter.description,
|
||||
gender = chatCharacter.gender,
|
||||
age = chatCharacter.age,
|
||||
mbti = chatCharacter.mbti,
|
||||
speechStyle = chatCharacter.speechStyle,
|
||||
speechPattern = chatCharacter.speechPattern,
|
||||
tags = chatCharacter.tagMappings.map { it.tag.tag },
|
||||
createdAt = createdAtStr,
|
||||
updatedAt = updatedAtStr
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterSearchListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterListResponse>
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imagePath: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(character: ChatCharacter, imageHost: String): OriginalWorkChatCharacterResponse {
|
||||
return OriginalWorkChatCharacterResponse(
|
||||
id = character.id!!,
|
||||
name = character.name,
|
||||
imagePath = character.imagePath?.let { "$imageHost/$it" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 연결된 캐릭터 결과 페이지 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkChatCharacterListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<OriginalWorkChatCharacterResponse>
|
||||
)
|
||||
@@ -1,178 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.utils.ImageBlurUtil
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/character/image")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCharacterImageController(
|
||||
private val imageService: CharacterImageService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val imageCloudFront: ImageContentCloudFront,
|
||||
private val langContext: LangContext,
|
||||
private val messageSource: SodaMessageSource,
|
||||
|
||||
@Value("\${cloud.aws.s3.content-bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val freeBucket: String
|
||||
) {
|
||||
|
||||
@GetMapping("/list")
|
||||
fun list(@RequestParam characterId: Long) = run {
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val list = imageService.listActiveByCharacter(characterId)
|
||||
.map { img ->
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
AdminCharacterImageResponse.fromWithUrl(img, signedUrl)
|
||||
}
|
||||
ApiResponse.ok(list)
|
||||
}
|
||||
|
||||
@GetMapping("/{imageId}")
|
||||
fun detail(@PathVariable imageId: Long) = run {
|
||||
val img = imageService.getById(imageId)
|
||||
val expiration = 5L * 60L * 1000L // 5분
|
||||
val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration)
|
||||
ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl))
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
fun register(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java)
|
||||
|
||||
// 업로드 키 생성
|
||||
val s3Key = buildS3Key(characterId = request.characterId)
|
||||
|
||||
// 원본 저장 (content-bucket)
|
||||
val imagePath = saveImageToBucket(s3Key, image, s3Bucket)
|
||||
|
||||
// 블러 생성 및 저장 (무료 이미지 버킷)
|
||||
val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket)
|
||||
|
||||
imageService.registerImage(
|
||||
characterId = request.characterId,
|
||||
imagePath = imagePath,
|
||||
blurImagePath = blurImagePath,
|
||||
imagePriceCan = request.imagePriceCan,
|
||||
messagePriceCan = request.messagePriceCan,
|
||||
isAdult = request.isAdult,
|
||||
triggers = request.triggers ?: emptyList()
|
||||
)
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping("/{imageId}/triggers")
|
||||
fun updateTriggers(
|
||||
@PathVariable imageId: Long,
|
||||
@RequestBody request: UpdateCharacterImageTriggersRequest
|
||||
) = run {
|
||||
if (!request.triggers.isNullOrEmpty()) {
|
||||
imageService.updateTriggers(imageId, request.triggers)
|
||||
}
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{imageId}")
|
||||
fun delete(@PathVariable imageId: Long) = run {
|
||||
imageService.deleteImage(imageId)
|
||||
val message = messageSource.getMessage("admin.chat.character.image_deleted", langContext.lang)
|
||||
ApiResponse.ok(null, message)
|
||||
}
|
||||
|
||||
@PutMapping("/orders")
|
||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||
if (request.characterId == null) {
|
||||
throw SodaException(messageKey = "admin.chat.character.character_id_required")
|
||||
}
|
||||
imageService.updateOrders(request.characterId, request.ids)
|
||||
val message = messageSource.getMessage("admin.chat.character.order_updated", langContext.lang)
|
||||
ApiResponse.ok(null, message)
|
||||
}
|
||||
|
||||
private fun buildS3Key(characterId: Long): String {
|
||||
val fileName = generateFileName("character-image")
|
||||
return "characters/$characterId/images/$fileName"
|
||||
}
|
||||
|
||||
private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException(messageKey = "admin.chat.character.image_save_failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String {
|
||||
try {
|
||||
// 멀티파트를 BufferedImage로 읽기
|
||||
val bytes = image.bytes
|
||||
val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes))
|
||||
?: throw SodaException(messageKey = "admin.chat.character.image_format_invalid")
|
||||
val blurred = ImageBlurUtil.blurFast(bimg)
|
||||
|
||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||
val baos = java.io.ByteArrayOutputStream()
|
||||
val format = when (image.contentType?.lowercase()) {
|
||||
"image/png" -> "png"
|
||||
else -> "jpg"
|
||||
}
|
||||
javax.imageio.ImageIO.write(blurred, format, baos)
|
||||
val inputStream = java.io.ByteArrayInputStream(baos.toByteArray())
|
||||
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = baos.size().toLong()
|
||||
metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg"
|
||||
|
||||
return s3Uploader.upload(
|
||||
inputStream = inputStream,
|
||||
bucket = bucket,
|
||||
filePath = filePath,
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException(messageKey = "admin.chat.character.blur_image_save_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.image.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.character.image.CharacterImage
|
||||
|
||||
// 요청 DTOs
|
||||
|
||||
data class RegisterCharacterImageRequest(
|
||||
@JsonProperty("characterId") val characterId: Long,
|
||||
@JsonProperty("imagePriceCan") val imagePriceCan: Long,
|
||||
@JsonProperty("messagePriceCan") val messagePriceCan: Long,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageTriggersRequest(
|
||||
@JsonProperty("triggers") val triggers: List<String>? = null
|
||||
)
|
||||
|
||||
data class UpdateCharacterImageOrdersRequest(
|
||||
@JsonProperty("characterId") val characterId: Long?,
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
|
||||
// 응답 DTOs
|
||||
|
||||
data class AdminCharacterImageResponse(
|
||||
val id: Long,
|
||||
val characterId: Long,
|
||||
val imagePriceCan: Long,
|
||||
val messagePriceCan: Long,
|
||||
val imageUrl: String,
|
||||
val triggers: List<String>,
|
||||
val isAdult: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse {
|
||||
return base(entity, signedUrl)
|
||||
}
|
||||
|
||||
private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse {
|
||||
return AdminCharacterImageResponse(
|
||||
id = entity.id!!,
|
||||
characterId = entity.chatCharacter.id!!,
|
||||
imagePriceCan = entity.imagePriceCan,
|
||||
messagePriceCan = entity.messagePriceCan,
|
||||
imageUrl = url,
|
||||
triggers = entity.triggerMappings.map { it.tag.word },
|
||||
isAdult = entity.isAdult
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.character.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminChatCharacterService(
|
||||
private val chatCharacterRepository: ChatCharacterRepository
|
||||
) {
|
||||
/**
|
||||
* 활성화된 캐릭터 목록을 페이징하여 조회
|
||||
*
|
||||
* @param pageable 페이징 정보
|
||||
* @return 페이징된 캐릭터 목록
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
|
||||
// isActive가 true인 캐릭터만 조회
|
||||
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
|
||||
|
||||
// 페이지 정보 생성
|
||||
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
|
||||
return ChatCharacterListPageResponse(
|
||||
totalCount = page.totalElements,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 페이지 요청 생성
|
||||
*
|
||||
* @param page 페이지 번호 (0부터 시작)
|
||||
* @param size 페이지 크기
|
||||
* @return 페이지 요청 객체
|
||||
*/
|
||||
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 상세 정보 조회
|
||||
*
|
||||
* @param characterId 캐릭터 ID
|
||||
* @param imageHost 이미지 호스트 URL
|
||||
* @return 캐릭터 상세 정보
|
||||
* @throws SodaException 캐릭터를 찾을 수 없는 경우
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse {
|
||||
val chatCharacter = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
|
||||
|
||||
return ChatCharacterDetailResponse.from(chatCharacter, imageHost)
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun searchCharacters(
|
||||
searchTerm: String,
|
||||
pageable: Pageable,
|
||||
imageHost: String = ""
|
||||
): Page<ChatCharacterListResponse> {
|
||||
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
|
||||
return characters.map { ChatCharacterListResponse.from(it, imageHost) }
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 등록 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerRegisterRequest(
|
||||
// 캐릭터 ID
|
||||
@JsonProperty("characterId") val characterId: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 수정 요청 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerUpdateRequest(
|
||||
// 배너 ID
|
||||
@JsonProperty("bannerId") val bannerId: Long,
|
||||
|
||||
// 캐릭터 ID (변경할 캐릭터)
|
||||
@JsonProperty("characterId") val characterId: Long? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO
|
||||
*/
|
||||
data class UpdateBannerOrdersRequest(
|
||||
// 배너 ID 목록 (순서대로 정렬됨)
|
||||
@JsonProperty("ids") val ids: List<Long>
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.dto
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerResponse(
|
||||
val id: Long,
|
||||
val imagePath: String,
|
||||
val characterId: Long,
|
||||
val characterName: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse {
|
||||
return ChatCharacterBannerResponse(
|
||||
id = banner.id!!,
|
||||
imagePath = "$imageHost/${banner.imagePath}",
|
||||
characterId = banner.chatCharacter.id!!,
|
||||
characterName = banner.chatCharacter.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 배너 목록 페이지 응답 DTO
|
||||
*/
|
||||
data class ChatCharacterBannerListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<ChatCharacterBannerResponse>
|
||||
)
|
||||
@@ -1,199 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.character.dto.OriginalWorkChatCharacterResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
/**
|
||||
* 원작(오리지널 작품) 관리자 API
|
||||
* - 원작 등록/수정/삭제
|
||||
* - 원작과 캐릭터 연결(배정) 및 해제
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/chat/original")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminOriginalWorkController(
|
||||
private val originalWorkService: AdminOriginalWorkService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
|
||||
/**
|
||||
* 원작 등록
|
||||
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun register(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
|
||||
|
||||
// 서비스 계층을 통해 원작을 생성
|
||||
val saved = originalWorkService.createOriginalWork(request)
|
||||
|
||||
// 이미지 업로드 후 이미지 경로 업데이트
|
||||
val imagePath = uploadImage(saved.id!!, image)
|
||||
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
|
||||
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 수정
|
||||
* - 이미지가 있으면 교체, 없으면 유지
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun update(
|
||||
@RequestPart(value = "image", required = false) image: MultipartFile?,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
|
||||
|
||||
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
|
||||
val imagePath = if (image != null && !image.isEmpty) {
|
||||
uploadImage(request.id, image)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
originalWorkService.updateOriginalWork(request, imagePath)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 삭제
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
fun delete(@PathVariable id: Long) = run {
|
||||
originalWorkService.deleteOriginalWork(id)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 목록(페이징)
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun list(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
|
||||
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 검색(관리자)
|
||||
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
|
||||
* - 페이징 제거: 전체 목록 반환
|
||||
*/
|
||||
@GetMapping("/search")
|
||||
fun search(
|
||||
@RequestParam("searchTerm") searchTerm: String
|
||||
) = run {
|
||||
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
|
||||
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작 상세
|
||||
*/
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: Long) = run {
|
||||
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작에 기존 캐릭터들을 배정
|
||||
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정
|
||||
*/
|
||||
@PostMapping("/{id}/assign-characters")
|
||||
fun assignCharacters(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||
) = run {
|
||||
originalWorkService.assignCharacters(id, body.characterIds)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 원작에서 캐릭터들 해제
|
||||
* - 캐릭터들의 originalWork를 null로 설정
|
||||
*/
|
||||
@PostMapping("/{id}/unassign-characters")
|
||||
fun unassignCharacters(
|
||||
@PathVariable id: Long,
|
||||
@RequestBody body: OriginalWorkAssignCharactersRequest
|
||||
) = run {
|
||||
originalWorkService.unassignCharacters(id, body.characterIds)
|
||||
ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
|
||||
* - 활성 캐릭터만 포함
|
||||
* - 응답 항목: 캐릭터 이미지(URL), 이름
|
||||
*/
|
||||
@GetMapping("/{id}/characters")
|
||||
fun listCharactersOfOriginal(
|
||||
@PathVariable id: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
|
||||
val content = pageRes.content.map { OriginalWorkChatCharacterResponse.from(it, imageHost) }
|
||||
ApiResponse.ok(
|
||||
OriginalWorkChatCharacterListPageResponse(
|
||||
totalCount = pageRes.totalElements,
|
||||
content = content
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/** 이미지 업로드 공통 처리 */
|
||||
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
throw SodaException(messageKey = "admin.chat.original.image_save_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
|
||||
/**
|
||||
* 원작 등록 요청 DTO
|
||||
*/
|
||||
data class OriginalWorkRegisterRequest(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("contentType") val contentType: String,
|
||||
@JsonProperty("category") val category: String,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean = false,
|
||||
@JsonProperty("description") val description: String = "",
|
||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("writer") val writer: String? = null,
|
||||
@JsonProperty("studio") val studio: String? = null,
|
||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 수정 요청 DTO (부분 수정 가능)
|
||||
*/
|
||||
data class OriginalWorkUpdateRequest(
|
||||
@JsonProperty("id") val id: Long,
|
||||
@JsonProperty("title") val title: String? = null,
|
||||
@JsonProperty("contentType") val contentType: String? = null,
|
||||
@JsonProperty("category") val category: String? = null,
|
||||
@JsonProperty("isAdult") val isAdult: Boolean? = null,
|
||||
@JsonProperty("description") val description: String? = null,
|
||||
@JsonProperty("originalWork") val originalWork: String? = null,
|
||||
@JsonProperty("originalLink") val originalLink: String? = null,
|
||||
@JsonProperty("writer") val writer: String? = null,
|
||||
@JsonProperty("studio") val studio: String? = null,
|
||||
@JsonProperty("originalLinks") val originalLinks: List<String>? = null,
|
||||
@JsonProperty("tags") val tags: List<String>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작 상세/목록 응답 DTO
|
||||
*/
|
||||
data class OriginalWorkResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val contentType: String,
|
||||
val category: String,
|
||||
val isAdult: Boolean,
|
||||
val description: String,
|
||||
val originalWork: String?,
|
||||
val originalLink: String?,
|
||||
val writer: String?,
|
||||
val studio: String?,
|
||||
val originalLinks: List<String>,
|
||||
val tags: List<String>,
|
||||
val imageUrl: String?
|
||||
) {
|
||||
companion object {
|
||||
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse {
|
||||
val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) {
|
||||
"$imageHost/${entity.imagePath}"
|
||||
} else {
|
||||
entity.imagePath
|
||||
}
|
||||
return OriginalWorkResponse(
|
||||
id = entity.id!!,
|
||||
title = entity.title,
|
||||
contentType = entity.contentType,
|
||||
category = entity.category,
|
||||
isAdult = entity.isAdult,
|
||||
description = entity.description,
|
||||
originalWork = entity.originalWork,
|
||||
originalLink = entity.originalLink,
|
||||
writer = entity.writer,
|
||||
studio = entity.studio,
|
||||
originalLinks = entity.originalLinks.map { it.url },
|
||||
tags = entity.tagMappings.map { it.tag.tag },
|
||||
imageUrl = fullImagePath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OriginalWorkPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<OriginalWorkResponse>
|
||||
)
|
||||
|
||||
/**
|
||||
* 원작-캐릭터 연결/해제 요청 DTO
|
||||
*/
|
||||
data class OriginalWorkAssignCharactersRequest(
|
||||
@JsonProperty("characterIds") val characterIds: List<Long>
|
||||
)
|
||||
@@ -1,278 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.chat.original.service
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
|
||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWork
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
|
||||
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
|
||||
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.PageRequest
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/**
|
||||
* 원작(오리지널 작품) 관련 관리자 서비스
|
||||
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
|
||||
*/
|
||||
@Service
|
||||
class AdminOriginalWorkService(
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val originalWorkTagRepository: OriginalWorkTagRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher
|
||||
) {
|
||||
|
||||
/** 원작 등록 (중복 제목 방지 포함) */
|
||||
@Transactional
|
||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||
throw SodaException(messageKey = "admin.chat.original.duplicate_title")
|
||||
}
|
||||
val entity = OriginalWork(
|
||||
title = request.title,
|
||||
contentType = request.contentType,
|
||||
category = request.category,
|
||||
isAdult = request.isAdult,
|
||||
description = request.description,
|
||||
originalWork = request.originalWork,
|
||||
originalLink = request.originalLink,
|
||||
writer = request.writer,
|
||||
studio = request.studio
|
||||
)
|
||||
// 링크 리스트 생성
|
||||
request.originalLinks?.filter { it.isNotBlank() }?.forEach { link ->
|
||||
entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity))
|
||||
}
|
||||
// 태그 매핑 생성 (기존 태그 재사용)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
normalized.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return originalWork
|
||||
}
|
||||
|
||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||
@Transactional
|
||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
|
||||
request.title?.let { ow.title = it }
|
||||
request.contentType?.let { ow.contentType = it }
|
||||
request.category?.let { ow.category = it }
|
||||
request.isAdult?.let { ow.isAdult = it }
|
||||
request.description?.let { ow.description = it }
|
||||
request.originalWork?.let { ow.originalWork = it }
|
||||
request.originalLink?.let { ow.originalLink = it }
|
||||
request.writer?.let { ow.writer = it }
|
||||
request.studio?.let { ow.studio = it }
|
||||
// 링크 리스트가 전달되면 기존 것을 교체
|
||||
request.originalLinks?.let { links ->
|
||||
ow.originalLinks.clear()
|
||||
links.filter { it.isNotBlank() }.forEach { link ->
|
||||
ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow))
|
||||
}
|
||||
}
|
||||
// 태그 변경사항만 반영 (요청이 null이면 변경 없음)
|
||||
request.tags?.let { tags ->
|
||||
val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet()
|
||||
val current = ow.tagMappings.map { it.tag.tag }.toSet()
|
||||
val toAdd = normalized.minus(current)
|
||||
val toRemove = current.minus(normalized)
|
||||
|
||||
if (toRemove.isNotEmpty()) {
|
||||
val itr = ow.tagMappings.iterator()
|
||||
while (itr.hasNext()) {
|
||||
val m = itr.next()
|
||||
if (toRemove.contains(m.tag.tag)) {
|
||||
itr.remove() // orphanRemoval=true로 매핑 삭제
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toAdd.isNotEmpty()) {
|
||||
toAdd.forEach { t ->
|
||||
val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t))
|
||||
ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imagePath != null) {
|
||||
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,
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 이미지 경로만 별도 갱신 */
|
||||
@Transactional
|
||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
ow.imagePath = imagePath
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 삭제 (소프트 삭제) */
|
||||
@Transactional
|
||||
fun deleteOriginalWork(id: Long) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
ow.isDeleted = true
|
||||
originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWork(id: Long): OriginalWork {
|
||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
}
|
||||
|
||||
/** 원작 페이징 조회 */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return originalWorkRepository.findByIsDeletedFalse(pageable)
|
||||
}
|
||||
|
||||
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
|
||||
// 원작 존재 및 소프트 삭제 여부 확인
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
|
||||
val safePage = if (page < 0) 0 else page
|
||||
val safeSize = when {
|
||||
size <= 0 -> 20
|
||||
size > 100 -> 100
|
||||
else -> size
|
||||
}
|
||||
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
|
||||
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
|
||||
}
|
||||
|
||||
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
|
||||
@Transactional(readOnly = true)
|
||||
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
|
||||
return originalWorkRepository.searchNoPaging(searchTerm)
|
||||
}
|
||||
|
||||
/** 원작에 기존 캐릭터들을 배정 */
|
||||
@Transactional
|
||||
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = ow }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 원작에서 캐릭터들 해제 */
|
||||
@Transactional
|
||||
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
|
||||
// 원작 존재 확인 (소프트 삭제 제외)
|
||||
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
if (characterIds.isEmpty()) return
|
||||
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
|
||||
characters.forEach { it.originalWork = null }
|
||||
chatCharacterRepository.saveAll(characters)
|
||||
}
|
||||
|
||||
/** 단일 캐릭터를 지정 원작에 배정 */
|
||||
@Transactional
|
||||
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
|
||||
val character = chatCharacterRepository.findById(characterId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.character.not_found") }
|
||||
|
||||
if (originalWorkId == 0L) {
|
||||
character.originalWork = null
|
||||
} else {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") }
|
||||
character.originalWork = ow
|
||||
}
|
||||
|
||||
chatCharacterRepository.save(character)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,6 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||
.and(audioContentHashTag.isActive.isTrue)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
@@ -51,9 +51,7 @@ class AdminContentService(
|
||||
searchWord: String,
|
||||
pageable: Pageable
|
||||
): GetAdminContentListResponse {
|
||||
if (searchWord.length < 2) {
|
||||
throw SodaException(messageKey = "admin.content.search_word_min_length")
|
||||
}
|
||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
||||
val audioContentAndThemeList = repository.getAudioContentList(
|
||||
status = status,
|
||||
@@ -84,7 +82,7 @@ class AdminContentService(
|
||||
@Transactional
|
||||
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
||||
val audioContent = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "admin.content.not_found")
|
||||
?: throw SodaException("없는 콘텐츠 입니다.")
|
||||
|
||||
if (request.isDefaultCoverImage) {
|
||||
audioContent.coverImage = "`profile/default_profile.png`"
|
||||
|
||||
@@ -33,19 +33,19 @@ class AdminContentBannerService(
|
||||
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
||||
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
||||
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||
throw SodaException("크리에이터를 선택하세요.")
|
||||
}
|
||||
|
||||
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
||||
throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||
throw SodaException("시리즈를 선택하세요.")
|
||||
}
|
||||
|
||||
if (request.type == AudioContentBannerType.LINK && request.link == null) {
|
||||
throw SodaException(messageKey = "admin.content.banner.link_required")
|
||||
throw SodaException("링크 url을 입력하세요.")
|
||||
}
|
||||
|
||||
if (request.type == AudioContentBannerType.EVENT && request.eventId == null) {
|
||||
throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||
throw SodaException("이벤트를 선택하세요.")
|
||||
}
|
||||
|
||||
val event = if (request.eventId != null && request.eventId > 0) {
|
||||
@@ -94,7 +94,7 @@ class AdminContentBannerService(
|
||||
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
||||
val audioContentBanner = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (image != null) {
|
||||
val fileName = generateFileName()
|
||||
@@ -124,22 +124,22 @@ class AdminContentBannerService(
|
||||
AudioContentBannerType.EVENT -> {
|
||||
if (request.eventId != null) {
|
||||
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||
?: throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||
?: throw SodaException("이벤트를 선택하세요.")
|
||||
|
||||
audioContentBanner.event = event
|
||||
} else {
|
||||
throw SodaException(messageKey = "admin.content.banner.event_required")
|
||||
throw SodaException("이벤트를 선택하세요.")
|
||||
}
|
||||
}
|
||||
|
||||
AudioContentBannerType.CREATOR -> {
|
||||
if (request.creatorId != null) {
|
||||
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||
?: throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||
?: throw SodaException("크리에이터를 선택하세요.")
|
||||
|
||||
audioContentBanner.creator = creator
|
||||
} else {
|
||||
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
||||
throw SodaException("크리에이터를 선택하세요.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,18 +147,18 @@ class AdminContentBannerService(
|
||||
if (request.link != null) {
|
||||
audioContentBanner.link = request.link
|
||||
} else {
|
||||
throw SodaException(messageKey = "admin.content.banner.link_required")
|
||||
throw SodaException("링크 url을 입력하세요.")
|
||||
}
|
||||
}
|
||||
|
||||
AudioContentBannerType.SERIES -> {
|
||||
if (request.seriesId != null) {
|
||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||
?: throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||
?: throw SodaException("시리즈를 선택하세요.")
|
||||
|
||||
audioContentBanner.series = series
|
||||
} else {
|
||||
throw SodaException(messageKey = "admin.content.banner.series_required")
|
||||
throw SodaException("시리즈를 선택하세요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class AdminContentCurationService(
|
||||
@Transactional
|
||||
fun createContentCuration(request: CreateContentCurationRequest) {
|
||||
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
val curation = AudioContentCuration(
|
||||
title = request.title,
|
||||
@@ -37,7 +37,7 @@ class AdminContentCurationService(
|
||||
@Transactional
|
||||
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
||||
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (request.title != null) {
|
||||
audioContentCuration.title = request.title
|
||||
@@ -85,7 +85,7 @@ class AdminContentCurationService(
|
||||
|
||||
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
||||
val curation = repository.findByIdOrNull(curationId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
return if (curation.isSeries) {
|
||||
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
||||
@@ -106,7 +106,7 @@ class AdminContentCurationService(
|
||||
fun addItemToCuration(request: AddItemToCurationRequest) {
|
||||
// 큐레이션 조회
|
||||
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (audioContentCuration.isSeries) {
|
||||
request.itemIdList.forEach { seriesId ->
|
||||
|
||||
@@ -28,7 +28,7 @@ class AdminHashTagCurationService(
|
||||
val isExists = repository.isExistsTag(tag = tag)
|
||||
|
||||
if (isExists) {
|
||||
throw SodaException(messageKey = "admin.content.hash_tag.already_registered")
|
||||
throw SodaException("이미 등록된 태그 입니다.")
|
||||
}
|
||||
|
||||
repository.save(
|
||||
@@ -42,7 +42,7 @@ class AdminHashTagCurationService(
|
||||
@Transactional
|
||||
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
||||
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (request.tag != null) {
|
||||
var tag = request.tag.trim()
|
||||
@@ -88,7 +88,7 @@ class AdminHashTagCurationService(
|
||||
@Transactional
|
||||
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
||||
val curation = repository.findByIdOrNull(id = request.curationId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
request.itemIdList.forEach { contentId ->
|
||||
val audioContent = audioContentRepository.findByIdAndActive(contentId)
|
||||
|
||||
@@ -4,8 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
@@ -21,9 +19,4 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
||||
fun searchSeriesList(
|
||||
@RequestParam(value = "search_word") searchWord: String
|
||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
||||
|
||||
@PutMapping
|
||||
fun modifySeries(
|
||||
@RequestBody request: AdminModifySeriesRequest
|
||||
) = ApiResponse.ok(service.modifySeries(request), "시리즈가 수정되었습니다.")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminContentSeriesService(
|
||||
private val repository: AdminContentSeriesRepository,
|
||||
private val genreRepository: AdminContentSeriesGenreRepository
|
||||
) {
|
||||
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||
val totalCount = repository.getSeriesTotalCount()
|
||||
val items = repository.getSeriesList(
|
||||
@@ -19,53 +12,10 @@ class AdminContentSeriesService(
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
|
||||
if (items.isNotEmpty()) {
|
||||
val ids = items.map { it.id }
|
||||
val seriesList = repository.findAllById(ids)
|
||||
val seriesMap = seriesList.associateBy { it.id }
|
||||
|
||||
items.forEach { item ->
|
||||
val s = seriesMap[item.id]
|
||||
if (s != null) {
|
||||
item.publishedDaysOfWeek = s.publishedDaysOfWeek.toList().sortedBy { it.ordinal }
|
||||
item.isOriginal = s.isOriginal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetAdminSeriesListResponse(totalCount, items)
|
||||
}
|
||||
|
||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||
return repository.searchSeriesList(searchWord)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun modifySeries(request: AdminModifySeriesRequest) {
|
||||
val series = repository.findByIdAndActiveTrue(request.seriesId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
if (request.publishedDaysOfWeek != null) {
|
||||
val days = request.publishedDaysOfWeek
|
||||
if (days.contains(SeriesPublishedDaysOfWeek.RANDOM) && days.size > 1) {
|
||||
throw SodaException(messageKey = "admin.content.series.random_days_conflict")
|
||||
}
|
||||
series.publishedDaysOfWeek.clear()
|
||||
series.publishedDaysOfWeek.addAll(days)
|
||||
}
|
||||
|
||||
if (request.genreId != null) {
|
||||
val genre = genreRepository.findActiveSeriesGenreById(request.genreId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
series.genre = genre
|
||||
}
|
||||
|
||||
if (request.isOriginal != null) {
|
||||
series.isOriginal = request.isOriginal
|
||||
}
|
||||
|
||||
if (request.isAdult != null) {
|
||||
series.isAdult = request.isAdult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
|
||||
data class AdminModifySeriesRequest(
|
||||
val seriesId: Long,
|
||||
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>?,
|
||||
val genreId: Long?,
|
||||
val isOriginal: Boolean?,
|
||||
val isAdult: Boolean?
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
|
||||
data class GetAdminSeriesListResponse(
|
||||
val totalCount: Int,
|
||||
@@ -18,10 +17,7 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
||||
val numberOfWorks: Long,
|
||||
val state: String,
|
||||
val isAdult: Boolean
|
||||
) {
|
||||
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
||||
var isOriginal: Boolean = false
|
||||
}
|
||||
)
|
||||
|
||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series.banner
|
||||
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.banner.UpdateBannerOrdersRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerListPageResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerUpdateRequest
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
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 org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/audio-content/series/banner")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminContentSeriesBannerController(
|
||||
private val bannerService: ContentSeriesBannerService,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val langContext: LangContext,
|
||||
private val messageSource: SodaMessageSource,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val s3Bucket: String,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
/**
|
||||
* 활성화된 배너 목록 조회 API
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
fun getBannerList(
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
) = run {
|
||||
val pageable = PageRequest.of(page, size)
|
||||
val banners = bannerService.getActiveBanners(pageable)
|
||||
val response = SeriesBannerListPageResponse(
|
||||
totalCount = banners.totalElements,
|
||||
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) }
|
||||
)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 상세 조회 API
|
||||
*/
|
||||
@GetMapping("/{bannerId}")
|
||||
fun getBannerDetail(@PathVariable bannerId: Long) = run {
|
||||
val banner = bannerService.getBannerById(bannerId)
|
||||
val response = SeriesBannerResponse.from(banner, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 등록 API
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
fun registerBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
|
||||
|
||||
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "")
|
||||
val imagePath = saveImage(banner.id!!, image)
|
||||
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
|
||||
val response = SeriesBannerResponse.from(updatedBanner, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정 API
|
||||
*/
|
||||
@PutMapping("/update")
|
||||
fun updateBanner(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = run {
|
||||
val objectMapper = ObjectMapper()
|
||||
val request = objectMapper.readValue(requestString, SeriesBannerUpdateRequest::class.java)
|
||||
// 배너 존재 확인
|
||||
bannerService.getBannerById(request.bannerId)
|
||||
val imagePath = saveImage(request.bannerId, image)
|
||||
val updated = bannerService.updateBanner(
|
||||
bannerId = request.bannerId,
|
||||
imagePath = imagePath,
|
||||
seriesId = request.seriesId
|
||||
)
|
||||
val response = SeriesBannerResponse.from(updated, imageHost)
|
||||
ApiResponse.ok(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 삭제 API (소프트 삭제)
|
||||
*/
|
||||
@DeleteMapping("/{bannerId}")
|
||||
fun deleteBanner(@PathVariable bannerId: Long) = run {
|
||||
bannerService.deleteBanner(bannerId)
|
||||
val message = messageSource.getMessage("admin.content.series.banner.delete_success", langContext.lang)
|
||||
ApiResponse.ok(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경 API
|
||||
*/
|
||||
@PutMapping("/orders")
|
||||
fun updateBannerOrders(
|
||||
@RequestBody request: UpdateBannerOrdersRequest
|
||||
) = run {
|
||||
bannerService.updateBannerOrders(request.ids)
|
||||
val message = messageSource.getMessage("admin.content.series.banner.reorder_success", langContext.lang)
|
||||
ApiResponse.ok(null, message)
|
||||
}
|
||||
|
||||
private fun saveImage(bannerId: Long, image: MultipartFile): String {
|
||||
try {
|
||||
val metadata = ObjectMetadata()
|
||||
metadata.contentLength = image.size
|
||||
val fileName = generateFileName("series-banner")
|
||||
return s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = s3Bucket,
|
||||
filePath = "series_banner/$bannerId/$fileName",
|
||||
metadata = metadata
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw SodaException(messageKey = "admin.content.series.banner.image_save_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series.banner.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
|
||||
|
||||
// 시리즈 배너 등록 요청 DTO
|
||||
data class SeriesBannerRegisterRequest(
|
||||
@JsonProperty("seriesId") val seriesId: Long
|
||||
)
|
||||
|
||||
// 시리즈 배너 수정 요청 DTO
|
||||
data class SeriesBannerUpdateRequest(
|
||||
@JsonProperty("bannerId") val bannerId: Long,
|
||||
@JsonProperty("seriesId") val seriesId: Long? = null
|
||||
)
|
||||
|
||||
// 시리즈 배너 응답 DTO
|
||||
data class SeriesBannerResponse(
|
||||
val id: Long,
|
||||
val imagePath: String,
|
||||
val seriesId: Long,
|
||||
val seriesTitle: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse {
|
||||
return SeriesBannerResponse(
|
||||
id = banner.id!!,
|
||||
imagePath = "$imageHost/${banner.imagePath}",
|
||||
seriesId = banner.series.id!!,
|
||||
seriesTitle = banner.series.title
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 시리즈 배너 목록 페이지 응답 DTO
|
||||
data class SeriesBannerListPageResponse(
|
||||
val totalCount: Long,
|
||||
val content: List<SeriesBannerResponse>
|
||||
)
|
||||
@@ -8,7 +8,6 @@ interface AdminContentSeriesGenreRepository : JpaRepository<SeriesGenre, Long>,
|
||||
|
||||
interface AdminContentSeriesGenreQueryRepository {
|
||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
||||
}
|
||||
|
||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
@@ -22,14 +21,4 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||
.orderBy(seriesGenre.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findActiveSeriesGenreById(id: Long): SeriesGenre? {
|
||||
return queryFactory
|
||||
.selectFrom(seriesGenre)
|
||||
.where(
|
||||
seriesGenre.id.eq(id)
|
||||
.and(seriesGenre.isActive.isTrue)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ class AdminContentSeriesGenreService(private val repository: AdminContentSeriesG
|
||||
@Transactional
|
||||
fun modifySeriesGenre(request: ModifySeriesGenreRequest) {
|
||||
if (request.genre == null && request.isAdult == null && request.isActive == null) {
|
||||
throw SodaException(messageKey = "admin.content.series.genre.no_changes")
|
||||
throw SodaException("변경사항이 없습니다.")
|
||||
}
|
||||
|
||||
val seriesGenre = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (request.genre != null) {
|
||||
seriesGenre.genre = request.genre
|
||||
|
||||
@@ -30,7 +30,7 @@ class AdminRecommendSeriesService(
|
||||
fun createRecommendSeries(image: MultipartFile, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
|
||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
val recommendSeries = RecommendSeries(isFree = request.isFree)
|
||||
recommendSeries.series = series
|
||||
@@ -49,7 +49,7 @@ class AdminRecommendSeriesService(
|
||||
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
|
||||
val recommendSeries = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (image != null) {
|
||||
val fileName = generateFileName()
|
||||
|
||||
@@ -5,11 +5,8 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
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 org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
@@ -21,8 +18,6 @@ class AdminContentThemeService(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminContentThemeRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@@ -42,27 +37,17 @@ class AdminContentThemeService(
|
||||
}
|
||||
|
||||
fun createTheme(theme: String, imagePath: String) {
|
||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageTranslationEvent(
|
||||
id = savedTheme.id!!,
|
||||
targetType = LanguageTranslationTargetType.CONTENT_THEME,
|
||||
waitTransactionCommit = true
|
||||
)
|
||||
)
|
||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||
}
|
||||
|
||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||
repository.findIdByTheme(request.theme)?.let {
|
||||
throw SodaException(messageKey = "admin.content.theme.already_registered")
|
||||
}
|
||||
repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteTheme(id: Long) {
|
||||
val theme = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
theme.theme = "${theme.theme}_deleted"
|
||||
theme.isActive = false
|
||||
|
||||
@@ -34,9 +34,7 @@ class AdminEventBannerService(
|
||||
startDateString: String,
|
||||
endDateString: String
|
||||
): Long {
|
||||
if (detail == null && link.isNullOrBlank()) {
|
||||
throw SodaException(messageKey = "admin.event.banner.detail_or_link_required")
|
||||
}
|
||||
if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요")
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||
@@ -104,7 +102,7 @@ class AdminEventBannerService(
|
||||
event.detailImage = detailImagePath
|
||||
event.popupImage = popupImagePath
|
||||
|
||||
return event.id ?: throw SodaException(messageKey = "admin.event.banner.create_failed")
|
||||
return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.")
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -120,10 +118,10 @@ class AdminEventBannerService(
|
||||
startDateString: String? = null,
|
||||
endDateString: String? = null
|
||||
) {
|
||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
if (id <= 0) throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
val event = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (thumbnail != null) {
|
||||
val metadata = ObjectMetadata()
|
||||
@@ -192,9 +190,9 @@ class AdminEventBannerService(
|
||||
|
||||
@Transactional
|
||||
fun delete(id: Long) {
|
||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
if (id <= 0) throw SodaException("잘못된 요청입니다.")
|
||||
val event = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
event.isActive = false
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository
|
||||
@Transactional
|
||||
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
|
||||
val chargeEvent = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "admin.charge_event.not_found_retry")
|
||||
?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.")
|
||||
|
||||
if (request.title != null) {
|
||||
chargeEvent.title = request.title
|
||||
|
||||
@@ -17,10 +17,10 @@ class AdminExplorerService(
|
||||
) {
|
||||
@Transactional
|
||||
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
|
||||
if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.title_required")
|
||||
if (request.title.isBlank()) throw SodaException("제목을 입력하세요.")
|
||||
|
||||
val findExplorerSection = repository.findByTitle(request.title)
|
||||
if (findExplorerSection != null) throw SodaException(messageKey = "admin.explorer.title_duplicate")
|
||||
if (findExplorerSection != null) throw SodaException("동일한 제목이 있습니다.")
|
||||
|
||||
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
|
||||
explorerSection.coloredTitle = request.coloredTitle
|
||||
@@ -37,7 +37,7 @@ class AdminExplorerService(
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_required")
|
||||
if (tags.size <= 0) throw SodaException("관심사를 선택하세요.")
|
||||
explorerSection.tags = tags
|
||||
|
||||
return repository.save(explorerSection).id!!
|
||||
@@ -53,14 +53,14 @@ class AdminExplorerService(
|
||||
request.coloredTitle == null &&
|
||||
request.isActive == null
|
||||
) {
|
||||
throw SodaException(messageKey = "admin.explorer.no_changes")
|
||||
throw SodaException("변경사항이 없습니다.")
|
||||
}
|
||||
|
||||
val explorerSection = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "admin.explorer.section_not_found")
|
||||
?: throw SodaException("해당하는 섹션이 없습니다.")
|
||||
|
||||
if (request.title != null) {
|
||||
if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.valid_title_required")
|
||||
if (request.title.isBlank()) throw SodaException("올바른 제목을 입력하세요.")
|
||||
explorerSection.title = request.title
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class AdminExplorerService(
|
||||
}
|
||||
}
|
||||
|
||||
if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_input_required")
|
||||
if (tags.size <= 0) throw SodaException("관심사를 입력하세요.")
|
||||
if (tags != explorerSection.tags) {
|
||||
explorerSection.tags.clear()
|
||||
explorerSection.tags.addAll(tags)
|
||||
|
||||
@@ -15,8 +15,6 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
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.RecommendLiveCreatorBannerRepository
|
||||
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
||||
@@ -51,8 +49,6 @@ class AdminLiveService(
|
||||
private val canRepository: CanRepository,
|
||||
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String,
|
||||
@@ -122,10 +118,10 @@ class AdminLiveService(
|
||||
endDateString: String,
|
||||
isAdult: Boolean
|
||||
): Long {
|
||||
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
|
||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||
?: throw SodaException(messageKey = "admin.live.creator_required")
|
||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||
@@ -138,15 +134,15 @@ class AdminLiveService(
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
if (startDate < nowDate) throw SodaException(messageKey = "admin.live.start_after_now")
|
||||
if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.")
|
||||
|
||||
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
if (endDate < nowDate) throw SodaException(messageKey = "admin.live.end_after_now")
|
||||
if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end")
|
||||
if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.")
|
||||
if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||
|
||||
val recommendCreatorBanner = RecommendLiveCreatorBanner(
|
||||
startDate = startDate,
|
||||
@@ -180,13 +176,13 @@ class AdminLiveService(
|
||||
isAdult: Boolean?
|
||||
) {
|
||||
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
|
||||
?: throw SodaException(messageKey = "admin.live.recommend_not_found_retry")
|
||||
?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.")
|
||||
|
||||
if (creatorId != null) {
|
||||
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
|
||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||
?: throw SodaException(messageKey = "admin.live.creator_required")
|
||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
|
||||
recommendCreatorBanner.creator = creator
|
||||
}
|
||||
@@ -222,13 +218,13 @@ class AdminLiveService(
|
||||
|
||||
if (endDate != null) {
|
||||
if (endDate <= startDate) {
|
||||
throw SodaException(messageKey = "admin.live.start_before_end")
|
||||
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||
}
|
||||
|
||||
recommendCreatorBanner.endDate = endDate
|
||||
} else {
|
||||
if (recommendCreatorBanner.endDate <= startDate) {
|
||||
throw SodaException(messageKey = "admin.live.start_before_end")
|
||||
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +237,7 @@ class AdminLiveService(
|
||||
.toLocalDateTime()
|
||||
|
||||
if (endDate <= recommendCreatorBanner.startDate) {
|
||||
throw SodaException(messageKey = "admin.live.end_after_start")
|
||||
throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.")
|
||||
}
|
||||
|
||||
recommendCreatorBanner.endDate = endDate
|
||||
@@ -270,10 +266,7 @@ class AdminLiveService(
|
||||
for (room in findRoomList) {
|
||||
room.isActive = false
|
||||
|
||||
val cancelReason = messageSource
|
||||
.getMessage("admin.live.cancel_reason.no_show", langContext.lang)
|
||||
.orEmpty()
|
||||
val roomCancel = LiveRoomCancel(cancelReason)
|
||||
val roomCancel = LiveRoomCancel("관리자에 의한 취소 - 노쇼")
|
||||
roomCancel.room = room
|
||||
roomCancelRepository.save(roomCancel)
|
||||
|
||||
@@ -293,10 +286,7 @@ class AdminLiveService(
|
||||
it.status = UseCanCalculateStatus.REFUND
|
||||
|
||||
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
||||
val canTitleTemplate = messageSource
|
||||
.getMessage("live.room.can_title", langContext.lang)
|
||||
.orEmpty()
|
||||
charge.title = String.format(canTitleTemplate, it.can)
|
||||
charge.title = "${it.can} 캔"
|
||||
charge.useCan = useCan
|
||||
|
||||
when (it.paymentGateway) {
|
||||
@@ -310,9 +300,7 @@ class AdminLiveService(
|
||||
status = PaymentStatus.COMPLETE,
|
||||
paymentGateway = it.paymentGateway
|
||||
)
|
||||
payment.method = messageSource
|
||||
.getMessage("live.room.refund_method", langContext.lang)
|
||||
.orEmpty()
|
||||
payment.method = "환불"
|
||||
charge.payment = payment
|
||||
|
||||
chargeRepository.save(charge)
|
||||
@@ -325,15 +313,11 @@ class AdminLiveService(
|
||||
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(
|
||||
FcmEvent(
|
||||
type = FcmEventType.CANCEL_LIVE,
|
||||
title = room.member!!.nickname,
|
||||
message = cancelMessage,
|
||||
message = "라이브 취소 : ${room.title}",
|
||||
recipientsMap = pushTokenListMap
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.admin.live.signature
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||
import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
@@ -18,11 +16,7 @@ import org.springframework.web.multipart.MultipartFile
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/live/signature-can")
|
||||
class AdminSignatureCanController(
|
||||
private val service: AdminSignatureCanService,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext
|
||||
) {
|
||||
class AdminSignatureCanController(private val service: AdminSignatureCanService) {
|
||||
@GetMapping
|
||||
fun getSignatureCanList(
|
||||
pageable: Pageable,
|
||||
@@ -38,7 +32,7 @@ class AdminSignatureCanController(
|
||||
@RequestParam("isAdult", required = false) isAdult: Boolean = false
|
||||
) = ApiResponse.ok(
|
||||
service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult),
|
||||
messageSource.getMessage("admin.signature_can.created", langContext.lang)
|
||||
"등록되었습니다."
|
||||
)
|
||||
|
||||
@PutMapping
|
||||
@@ -51,7 +45,7 @@ class AdminSignatureCanController(
|
||||
@RequestParam("isAdult", required = false) isAdult: Boolean?
|
||||
) = run {
|
||||
if (can == null && time == null && image == null && isActive == null && isAdult == null) {
|
||||
throw SodaException(messageKey = "admin.signature_can.no_changes")
|
||||
throw SodaException("변경사항이 없습니다.")
|
||||
}
|
||||
|
||||
ApiResponse.ok(
|
||||
@@ -63,7 +57,7 @@ class AdminSignatureCanController(
|
||||
isActive = isActive,
|
||||
isAdult = isAdult
|
||||
),
|
||||
messageSource.getMessage("admin.signature_can.updated", langContext.lang)
|
||||
"수정되었습니다."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ class AdminSignatureCanService(
|
||||
|
||||
@Transactional
|
||||
fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) {
|
||||
if (creatorId < 1) throw SodaException(messageKey = "admin.signature_can.creator_required")
|
||||
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
||||
if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range")
|
||||
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
||||
if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.")
|
||||
|
||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||
?: throw SodaException(messageKey = "admin.signature_can.creator_required")
|
||||
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||
|
||||
val signatureCan = SignatureCan(can = can, isAdult = isAdult)
|
||||
signatureCan.creator = creator
|
||||
@@ -76,15 +76,15 @@ class AdminSignatureCanService(
|
||||
isAdult: Boolean?
|
||||
) {
|
||||
val signatureCan = repository.findByIdOrNull(id = id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (can != null) {
|
||||
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
||||
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
||||
signatureCan.can = can
|
||||
}
|
||||
|
||||
if (time != null) {
|
||||
if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range")
|
||||
if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.")
|
||||
signatureCan.time = time
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepositor
|
||||
@Transactional
|
||||
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
||||
val entity = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "admin.media_partner.invalid_access")
|
||||
?: throw SodaException("잘못된 접근입니다")
|
||||
|
||||
if (request.mediaGroup != null) {
|
||||
entity.mediaGroup = request.mediaGroup
|
||||
|
||||
@@ -36,12 +36,6 @@ class AdminMemberController(private val service: AdminMemberService) {
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(service.searchMember(searchWord, pageable))
|
||||
|
||||
@GetMapping("/search-by-nickname")
|
||||
fun searchMemberByNickname(
|
||||
@RequestParam(value = "search_word") searchWord: String,
|
||||
@RequestParam(value = "size", required = false) size: Int?
|
||||
) = ApiResponse.ok(service.searchMemberByNickname(searchWord = searchWord, size = size ?: 20))
|
||||
|
||||
@GetMapping("/creator/all/list")
|
||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ interface AdminMemberQueryRepository {
|
||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||
fun findByIdAndActive(memberId: Long): Member?
|
||||
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
||||
}
|
||||
|
||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||
@@ -122,22 +121,4 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
||||
.orderBy(member.id.desc())
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun searchMemberByNickname(searchWord: String, limit: Long): List<AdminSimpleMemberResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QAdminSimpleMemberResponse(
|
||||
member.id,
|
||||
member.nickname
|
||||
)
|
||||
)
|
||||
.from(member)
|
||||
.where(
|
||||
member.nickname.contains(searchWord)
|
||||
.and(member.isActive.isTrue)
|
||||
)
|
||||
.orderBy(member.id.desc())
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
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.MemberProvider
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -19,8 +17,6 @@ import java.time.format.DateTimeFormatter
|
||||
class AdminMemberService(
|
||||
private val repository: AdminMemberRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val messageSource: SodaMessageSource,
|
||||
private val langContext: LangContext,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
@@ -28,7 +24,7 @@ class AdminMemberService(
|
||||
@Transactional
|
||||
fun updateMember(request: UpdateMemberRequest) {
|
||||
val member = repository.findByIdOrNull(request.id)
|
||||
?: throw SodaException(messageKey = "admin.member.not_found")
|
||||
?: throw SodaException("해당 유저가 없습니다.")
|
||||
|
||||
if (member.role != request.userType) {
|
||||
member.role = request.userType
|
||||
@@ -48,7 +44,7 @@ class AdminMemberService(
|
||||
}
|
||||
|
||||
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
||||
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
|
||||
val memberList = processMemberListToGetAdminMemberListResponseItemList(
|
||||
memberList = repository.searchMember(
|
||||
@@ -75,7 +71,7 @@ class AdminMemberService(
|
||||
}
|
||||
|
||||
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
||||
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||
val totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
|
||||
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
|
||||
memberList = repository.searchMember(
|
||||
@@ -96,18 +92,18 @@ class AdminMemberService(
|
||||
.asSequence()
|
||||
.map {
|
||||
val userType = when (it.role) {
|
||||
MemberRole.ADMIN -> messageSource.getMessage("admin.member.role.admin", langContext.lang).orEmpty()
|
||||
MemberRole.USER -> messageSource.getMessage("admin.member.role.user", langContext.lang).orEmpty()
|
||||
MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
|
||||
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty()
|
||||
MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty()
|
||||
MemberRole.ADMIN -> "관리자"
|
||||
MemberRole.USER -> "일반회원"
|
||||
MemberRole.CREATOR -> "크리에이터"
|
||||
MemberRole.AGENT -> "에이전트"
|
||||
MemberRole.BOT -> "봇"
|
||||
}
|
||||
|
||||
val loginType = when (it.provider) {
|
||||
MemberProvider.EMAIL -> messageSource.getMessage("member.provider.email", langContext.lang).orEmpty()
|
||||
MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty()
|
||||
MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty()
|
||||
MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty()
|
||||
MemberProvider.EMAIL -> "이메일"
|
||||
MemberProvider.KAKAO -> "카카오"
|
||||
MemberProvider.GOOGLE -> "구글"
|
||||
MemberProvider.APPLE -> "애플"
|
||||
}
|
||||
|
||||
val signUpDate = it.createdAt!!
|
||||
@@ -149,16 +145,10 @@ class AdminMemberService(
|
||||
return repository.getCreatorAllList()
|
||||
}
|
||||
|
||||
fun searchMemberByNickname(searchWord: String, size: Int = 20): List<AdminSimpleMemberResponse> {
|
||||
if (searchWord.length < 2) throw SodaException(messageKey = "admin.member.search_word_min_length")
|
||||
val limit = if (size <= 0) 20 else size
|
||||
return repository.searchMemberByNickname(searchWord = searchWord, limit = limit.toLong())
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(request: ResetPasswordRequest) {
|
||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||
?: throw SodaException(messageKey = "admin.member.reset_password_invalid")
|
||||
?: throw SodaException("잘못된 회원정보입니다.\n다시 시도해 주세요.")
|
||||
|
||||
member.password = passwordEncoder.encode(member.email.split("@")[0])
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.member
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
/**
|
||||
* 관리자용 간단 회원 응답 DTO
|
||||
* 닉네임 검색 결과로 사용되며 charge 등에서 memberId 선택에 활용된다.
|
||||
*/
|
||||
data class AdminSimpleMemberResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val nickname: String
|
||||
)
|
||||
@@ -35,9 +35,7 @@ class AdminMemberTagService(
|
||||
}
|
||||
|
||||
private fun tagExistCheck(request: CreateMemberTagRequest) {
|
||||
repository.findByTag(request.tag)?.let {
|
||||
throw SodaException(messageKey = "admin.member.tag.already_registered")
|
||||
}
|
||||
repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") }
|
||||
}
|
||||
|
||||
private fun createTag(tag: String, imagePath: String, isAdult: Boolean) {
|
||||
@@ -53,7 +51,7 @@ class AdminMemberTagService(
|
||||
@Transactional
|
||||
fun deleteTag(id: Long) {
|
||||
val creatorTag = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
creatorTag.tag = "${creatorTag.tag}_deleted"
|
||||
creatorTag.isActive = false
|
||||
@@ -62,7 +60,7 @@ class AdminMemberTagService(
|
||||
@Transactional
|
||||
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
|
||||
val creatorTag = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
|
||||
creatorTag.tag = request.tag
|
||||
|
||||
@@ -2,17 +2,14 @@ package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
import kr.co.vividnext.sodalive.point.PointRewardPolicy
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import kr.co.vividnext.sodalive.useraction.PolicyType
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
data class CreatePointRewardPolicyRequest(
|
||||
val title: String,
|
||||
val policyType: PolicyType,
|
||||
val actionType: ActionType,
|
||||
val threshold: Int,
|
||||
val availableCount: Int,
|
||||
val pointAmount: Int,
|
||||
val startDate: String,
|
||||
val endDate: String
|
||||
@@ -22,10 +19,8 @@ data class CreatePointRewardPolicyRequest(
|
||||
|
||||
return PointRewardPolicy(
|
||||
title = title,
|
||||
policyType = policyType,
|
||||
actionType = actionType,
|
||||
threshold = threshold,
|
||||
availableCount = availableCount,
|
||||
pointAmount = pointAmount,
|
||||
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
|
||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.point
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||
import kr.co.vividnext.sodalive.useraction.PolicyType
|
||||
|
||||
data class GetPointRewardPolicyListResponse(
|
||||
val totalCount: Int,
|
||||
@@ -12,10 +11,8 @@ data class GetPointRewardPolicyListResponse(
|
||||
data class GetPointRewardPolicyListItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val policyType: PolicyType,
|
||||
val actionType: ActionType,
|
||||
val threshold: Int,
|
||||
val availableCount: Int,
|
||||
val pointAmount: Int,
|
||||
val startDate: String,
|
||||
val endDate: String,
|
||||
|
||||
@@ -33,10 +33,8 @@ class PointPolicyQueryRepositoryImpl(
|
||||
QGetPointRewardPolicyListItem(
|
||||
pointRewardPolicy.id,
|
||||
pointRewardPolicy.title,
|
||||
pointRewardPolicy.policyType,
|
||||
pointRewardPolicy.actionType,
|
||||
pointRewardPolicy.threshold,
|
||||
pointRewardPolicy.availableCount,
|
||||
pointRewardPolicy.pointAmount,
|
||||
getFormattedDate(pointRewardPolicy.startDate),
|
||||
getFormattedDate(pointRewardPolicy.endDate),
|
||||
|
||||
@@ -26,7 +26,7 @@ class PointPolicyService(private val repository: PointPolicyRepository) {
|
||||
@Transactional
|
||||
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
||||
val pointPolicy = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "admin.point.policy.invalid_access")
|
||||
?: throw SodaException("잘못된 접근입니다.")
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.NumberExpression
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||
@@ -66,7 +67,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val firstPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
val repeatPaymentCount = CaseBuilder()
|
||||
@@ -78,7 +79,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
val repeatPaymentTotalAmount = CaseBuilder()
|
||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
val allPaymentCount = CaseBuilder()
|
||||
@@ -96,7 +97,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||
)
|
||||
.then(adTrackingHistory.price)
|
||||
.otherwise(0.toBigDecimal())
|
||||
.otherwise(Expressions.constant(0.0))
|
||||
.sum()
|
||||
|
||||
return queryFactory
|
||||
@@ -110,11 +111,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
loginCount,
|
||||
signUpCount,
|
||||
firstPaymentCount,
|
||||
firstPaymentTotalAmount,
|
||||
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||
repeatPaymentCount,
|
||||
repeatPaymentTotalAmount,
|
||||
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||
allPaymentCount,
|
||||
allPaymentTotalAmount
|
||||
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||
)
|
||||
)
|
||||
.from(adTrackingHistory)
|
||||
@@ -147,4 +148,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
}
|
||||
|
||||
private fun roundedValueDecimalPlaces2(valueExpression: NumberExpression<Double>): NumberExpression<Double> {
|
||||
return Expressions.numberTemplate(
|
||||
Double::class.java,
|
||||
"ROUND({0}, {1})",
|
||||
valueExpression,
|
||||
2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetAdminAdStatisticsResponse(
|
||||
val totalCount: Int,
|
||||
@@ -17,9 +16,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
||||
val loginCount: Int,
|
||||
val signUpCount: Int,
|
||||
val firstPaymentCount: Int,
|
||||
val firstPaymentTotalAmount: BigDecimal,
|
||||
val firstPaymentTotalAmount: Double,
|
||||
val repeatPaymentCount: Int,
|
||||
val repeatPaymentTotalAmount: BigDecimal,
|
||||
val repeatPaymentTotalAmount: Double,
|
||||
val allPaymentCount: Int,
|
||||
val allPaymentTotalAmount: BigDecimal
|
||||
val allPaymentTotalAmount: Double
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
||||
)
|
||||
|
||||
if (dateRange == null) {
|
||||
throw SodaException(messageKey = "admin.member.statistics.invalid_access")
|
||||
throw SodaException("잘못된 접근입니다.")
|
||||
}
|
||||
|
||||
var startDateTime = startDate.atStartOfDay()
|
||||
|
||||
@@ -17,7 +17,7 @@ class AlarmController(private val service: AlarmService) {
|
||||
fun getSlotQuantityAndPrice(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getSlotQuantityAndPrice(memberId = member.id!!)
|
||||
@@ -29,7 +29,7 @@ class AlarmController(private val service: AlarmService) {
|
||||
@PathVariable("container") container: String,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.buyExtraSlot(
|
||||
|
||||
@@ -53,7 +53,7 @@ class AlarmService(
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw SodaException(messageKey = "alarm.error.already_purchased")
|
||||
throw SodaException("이미 구매하셨습니다")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.GetAuditionListItem
|
||||
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse
|
||||
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
|
||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
|
||||
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
|
||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelResponse
|
||||
|
||||
data class GetHomeResponse(
|
||||
val liveList: List<GetRoomListResponse>,
|
||||
val creatorRanking: List<GetExplorerSectionCreatorResponse>,
|
||||
val latestContentThemeList: List<String>,
|
||||
val latestContentList: List<AudioContentMainItem>,
|
||||
val bannerList: List<GetAudioContentBannerResponse>,
|
||||
val eventBannerList: GetEventResponse,
|
||||
val originalAudioDramaList: List<GetSeriesListResponse.SeriesListItem>,
|
||||
val auditionList: List<GetAuditionListItem>,
|
||||
val dayOfWeekSeriesList: List<GetSeriesListResponse.SeriesListItem>,
|
||||
val popularCharacters: List<Character>,
|
||||
val contentRanking: List<GetAudioContentRankingItem>,
|
||||
val recommendChannelList: List<RecommendChannelResponse>,
|
||||
val freeContentList: List<AudioContentMainItem>,
|
||||
val pointAvailableContentList: List<AudioContentMainItem>,
|
||||
val recommendContentList: List<AudioContentMainItem>,
|
||||
val curationList: List<GetContentCurationResponse>
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/home")
|
||||
class HomeController(private val service: HomeService) {
|
||||
@GetMapping
|
||||
fun fetchData(
|
||||
@RequestParam timezone: String,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.fetchData(
|
||||
timezone = timezone,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/latest-content")
|
||||
fun getLatestContentByTheme(
|
||||
@RequestParam("theme") theme: String,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getLatestContentByTheme(
|
||||
theme = theme,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/day-of-week-series")
|
||||
fun getDayOfWeekSeriesList(
|
||||
@RequestParam("dayOfWeek") dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getDayOfWeekSeriesList(
|
||||
dayOfWeek = dayOfWeek,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 추천 콘텐츠만 새로고침하기 위한 엔드포인트
|
||||
@GetMapping("/recommend-contents")
|
||||
fun getRecommendContents(
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 콘텐츠 랭킹 엔드포인트
|
||||
@GetMapping("/content-ranking")
|
||||
fun getContentRanking(
|
||||
@RequestParam("sort", required = false) sort: ContentRankingSortType? = null,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@RequestParam("offset", required = false) offset: Long? = null,
|
||||
@RequestParam("limit", required = false) limit: Long? = null,
|
||||
@RequestParam("theme", required = false) theme: String? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getContentRankingBySort(
|
||||
sort = sort ?: ContentRankingSortType.REVENUE,
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
offset = offset,
|
||||
limit = limit,
|
||||
theme = theme,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.home
|
||||
|
||||
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.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
|
||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||
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.translation.ContentTranslationRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberService
|
||||
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
|
||||
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
|
||||
import kr.co.vividnext.sodalive.rank.RankingRepository
|
||||
import kr.co.vividnext.sodalive.rank.RankingService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
@Service
|
||||
class HomeService(
|
||||
private val memberService: MemberService,
|
||||
private val liveRoomService: LiveRoomService,
|
||||
private val auditionService: AuditionService,
|
||||
private val seriesService: ContentSeriesService,
|
||||
private val contentService: AudioContentService,
|
||||
private val bannerService: AudioContentBannerService,
|
||||
private val curationService: AudioContentCurationService,
|
||||
private val contentThemeService: AudioContentThemeService,
|
||||
private val recommendChannelService: RecommendChannelQueryService,
|
||||
|
||||
private val characterService: ChatCharacterService,
|
||||
private val rankingService: RankingService,
|
||||
private val rankingRepository: RankingRepository,
|
||||
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}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
companion object {
|
||||
private const val RECOMMEND_TARGET_SIZE = 30
|
||||
private const val RECOMMEND_MAX_ATTEMPTS = 3
|
||||
}
|
||||
|
||||
fun fetchData(
|
||||
timezone: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): GetHomeResponse {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val liveList = liveRoomService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.NOW,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(10),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
val creatorRanking = rankingRepository
|
||||
.getCreatorRankings()
|
||||
.filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
.map {
|
||||
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
|
||||
val follow = if (memberId != null) {
|
||||
explorerQueryRepository.isFollow(it.id!!, memberId = memberId)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
it.toExplorerSectionCreator(imageHost, follow, followerCount = followerCount)
|
||||
}
|
||||
|
||||
val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType
|
||||
)
|
||||
|
||||
val latestContentList = contentService.getLatestContentByTheme(
|
||||
theme = latestContentThemeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||
|
||||
val eventBannerList = GetEventResponse(
|
||||
totalCount = 0,
|
||||
eventList = emptyList()
|
||||
)
|
||||
|
||||
val bannerList = bannerService.getBannerList(
|
||||
tabId = 1,
|
||||
memberId = member?.id,
|
||||
isAdult = isAdult
|
||||
)
|
||||
|
||||
// 오직 보이스온에서만
|
||||
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
orderByRandom = true
|
||||
)
|
||||
|
||||
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
|
||||
|
||||
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
|
||||
|
||||
// 요일별 시리즈
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = getDayOfWeekByTimezone(timezone)
|
||||
)
|
||||
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||
|
||||
// 인기 캐릭터 조회
|
||||
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||
|
||||
val currentDateTime = LocalDateTime.now()
|
||||
val startDate = currentDateTime
|
||||
.withHour(15)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
.minusWeeks(1)
|
||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
val endDate = startDate
|
||||
.plusDays(6)
|
||||
|
||||
val contentRanking = rankingService.getContentRanking(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
startDate = startDate.minusDays(1),
|
||||
endDate = endDate,
|
||||
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(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
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(
|
||||
theme = contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = true,
|
||||
contentType = contentType
|
||||
),
|
||||
contentType = contentType,
|
||||
isFree = true,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
||||
|
||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||
theme = emptyList(),
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true,
|
||||
isPointAvailableOnly = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||
|
||||
val curationList = curationService.getContentCurationList(
|
||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
memberId = memberId
|
||||
)
|
||||
|
||||
return GetHomeResponse(
|
||||
liveList = liveList,
|
||||
creatorRanking = creatorRanking,
|
||||
latestContentThemeList = latestContentThemeList,
|
||||
latestContentList = translatedLatestContentList,
|
||||
bannerList = bannerList,
|
||||
eventBannerList = eventBannerList,
|
||||
originalAudioDramaList = translatedOriginalAudioDramaList,
|
||||
auditionList = auditionList,
|
||||
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
|
||||
popularCharacters = translatedPopularCharacters,
|
||||
contentRanking = translatedContentRanking,
|
||||
recommendChannelList = translatedRecommendChannelList,
|
||||
freeContentList = translatedFreeContentList,
|
||||
pointAvailableContentList = translatedPointAvailableContentList,
|
||||
recommendContentList = getRecommendContentList(
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
contentType = contentType,
|
||||
member = member
|
||||
),
|
||||
curationList = curationList
|
||||
)
|
||||
}
|
||||
|
||||
fun getLatestContentByTheme(
|
||||
theme: String,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<AudioContentMainItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val themeList = if (theme.isBlank()) {
|
||||
contentThemeService.getActiveThemeOfContent(
|
||||
isAdult = isAdult,
|
||||
isFree = true,
|
||||
contentType = contentType
|
||||
)
|
||||
} else {
|
||||
listOf(theme)
|
||||
}
|
||||
|
||||
val contentList = contentService.getLatestContentByTheme(
|
||||
theme = themeList,
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
return getTranslatedContentList(contentList = contentList)
|
||||
}
|
||||
|
||||
fun getDayOfWeekSeriesList(
|
||||
dayOfWeek: SeriesPublishedDaysOfWeek,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<GetSeriesListResponse.SeriesListItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
dayOfWeek = dayOfWeek
|
||||
)
|
||||
|
||||
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
|
||||
}
|
||||
|
||||
fun getContentRankingBySort(
|
||||
sort: ContentRankingSortType,
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
offset: Long?,
|
||||
limit: Long?,
|
||||
theme: String?,
|
||||
member: Member?
|
||||
): List<GetAudioContentRankingItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val currentDateTime = LocalDateTime.now()
|
||||
val startDate = currentDateTime
|
||||
.withHour(15)
|
||||
.withMinute(0)
|
||||
.withSecond(0)
|
||||
.minusWeeks(1)
|
||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
val endDate = startDate.plusDays(6)
|
||||
|
||||
return rankingService.getContentRanking(
|
||||
memberId = memberId,
|
||||
isAdult = isAdult,
|
||||
contentType = contentType,
|
||||
startDate = startDate.minusDays(1),
|
||||
endDate = endDate,
|
||||
offset = offset ?: 0,
|
||||
limit = limit ?: 12,
|
||||
sort = sort,
|
||||
theme = theme ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDayOfWeekByTimezone(timezone: String): SeriesPublishedDaysOfWeek {
|
||||
val systemTime = LocalDateTime.now()
|
||||
val zoneId = ZoneId.of(timezone)
|
||||
val zonedDateTime = systemTime.atZone(ZoneId.systemDefault()).withZoneSameInstant(zoneId)
|
||||
|
||||
val dayToSeriesPublishedDaysOfWeek = mapOf(
|
||||
DayOfWeek.MONDAY to SeriesPublishedDaysOfWeek.MON,
|
||||
DayOfWeek.TUESDAY to SeriesPublishedDaysOfWeek.TUE,
|
||||
DayOfWeek.WEDNESDAY to SeriesPublishedDaysOfWeek.WED,
|
||||
DayOfWeek.THURSDAY to SeriesPublishedDaysOfWeek.THU,
|
||||
DayOfWeek.FRIDAY to SeriesPublishedDaysOfWeek.FRI,
|
||||
DayOfWeek.SATURDAY to SeriesPublishedDaysOfWeek.SAT,
|
||||
DayOfWeek.SUNDAY to SeriesPublishedDaysOfWeek.SUN
|
||||
)
|
||||
|
||||
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
|
||||
}
|
||||
|
||||
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
|
||||
fun getRecommendContentList(
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
member: Member?
|
||||
): List<AudioContentMainItem> {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회
|
||||
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2)
|
||||
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE)
|
||||
var attempt = 0
|
||||
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) {
|
||||
attempt += 1
|
||||
val batch = contentService.getLatestContentByTheme(
|
||||
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회
|
||||
contentType = contentType,
|
||||
offset = 0,
|
||||
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회
|
||||
isFree = false,
|
||||
isAdult = isAdult,
|
||||
orderByRandom = true
|
||||
).filter {
|
||||
if (memberId != null) {
|
||||
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
for (item in batch) {
|
||||
if (result.size >= RECOMMEND_TARGET_SIZE) break
|
||||
if (seen.add(item.contentId)) {
|
||||
result.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/live")
|
||||
class LiveApiController(
|
||||
private val service: LiveApiService
|
||||
) {
|
||||
@GetMapping
|
||||
fun fetchData(
|
||||
@RequestParam timezone: String,
|
||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.fetchData(
|
||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||
contentType = contentType ?: ContentType.ALL,
|
||||
timezone = timezone,
|
||||
member = member
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||
import kr.co.vividnext.sodalive.content.ContentType
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||
import kr.co.vividnext.sodalive.live.recommend.LiveRecommendService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class LiveApiService(
|
||||
private val liveService: LiveRoomService,
|
||||
private val contentService: AudioContentService,
|
||||
private val recommendService: LiveRecommendService,
|
||||
private val creatorCommunityService: CreatorCommunityService,
|
||||
|
||||
private val blockMemberRepository: BlockMemberRepository
|
||||
) {
|
||||
fun fetchData(
|
||||
isAdultContentVisible: Boolean,
|
||||
contentType: ContentType,
|
||||
timezone: String,
|
||||
member: Member?
|
||||
): LiveMainResponse {
|
||||
val memberId = member?.id
|
||||
val isAdult = member?.auth != null && isAdultContentVisible
|
||||
|
||||
val liveOnAirRoomList = liveService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.NOW,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(20),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
val communityPostList = if (memberId != null) {
|
||||
creatorCommunityService.getLatestPostListFromCreatorsYouFollow(
|
||||
timezone = timezone,
|
||||
memberId = memberId,
|
||||
isAdult = isAdult
|
||||
)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
val recommendLiveList = recommendService.getRecommendLive(member)
|
||||
|
||||
val latestFinishedLiveList = liveService.getLatestFinishedLive(member)
|
||||
|
||||
val replayLive = contentService.getLatestContentByTheme(
|
||||
theme = listOf("다시듣기"),
|
||||
contentType = contentType,
|
||||
isFree = false,
|
||||
isAdult = isAdult
|
||||
)
|
||||
.filter { content ->
|
||||
if (memberId != null) {
|
||||
!blockMemberRepository.isBlocked(blockedMemberId = memberId, memberId = content.creatorId)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val followingChannelList = if (memberId != null) {
|
||||
recommendService.getFollowingChannelList(member)
|
||||
} else {
|
||||
listOf()
|
||||
}
|
||||
|
||||
val liveReservationRoomList = liveService.getRoomList(
|
||||
dateString = null,
|
||||
status = LiveRoomStatus.RESERVATION,
|
||||
isAdultContentVisible = isAdultContentVisible,
|
||||
pageable = Pageable.ofSize(10),
|
||||
member = member,
|
||||
timezone = timezone
|
||||
)
|
||||
|
||||
return LiveMainResponse(
|
||||
liveOnAirRoomList = liveOnAirRoomList,
|
||||
communityPostList = communityPostList,
|
||||
recommendLiveList = recommendLiveList,
|
||||
latestFinishedLiveList = latestFinishedLiveList,
|
||||
replayLive = replayLive,
|
||||
followingChannelList = followingChannelList,
|
||||
liveReservationRoomList = liveReservationRoomList
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.api.live
|
||||
|
||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
|
||||
import kr.co.vividnext.sodalive.live.recommend.GetRecommendChannelResponse
|
||||
import kr.co.vividnext.sodalive.live.recommend.GetRecommendLiveResponse
|
||||
import kr.co.vividnext.sodalive.live.room.GetLatestFinishedLiveResponse
|
||||
import kr.co.vividnext.sodalive.live.room.GetRoomListResponse
|
||||
|
||||
data class LiveMainResponse(
|
||||
val liveOnAirRoomList: List<GetRoomListResponse>,
|
||||
val communityPostList: List<GetCommunityPostListResponse>,
|
||||
val recommendLiveList: List<GetRecommendLiveResponse>,
|
||||
val latestFinishedLiveList: List<GetLatestFinishedLiveResponse>,
|
||||
val replayLive: List<AudioContentMainItem>,
|
||||
val followingChannelList: List<GetRecommendChannelResponse>,
|
||||
val liveReservationRoomList: List<GetRoomListResponse>
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class AuditionController(private val service: AuditionService) {
|
||||
@PathVariable id: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(service.getAuditionDetail(auditionId = id))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface AuditionQueryRepository {
|
||||
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
||||
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
|
||||
}
|
||||
|
||||
class AuditionQueryRepositoryImpl(
|
||||
@@ -95,27 +94,4 @@ class AuditionQueryRepositoryImpl(
|
||||
.where(audition.id.eq(auditionId))
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
||||
var where = audition.isActive.isTrue
|
||||
.and(audition.status.eq(AuditionStatus.IN_PROGRESS))
|
||||
|
||||
if (!isAdult) {
|
||||
where = where.and(audition.isAdult.isFalse)
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionListItem(
|
||||
audition.id,
|
||||
audition.title,
|
||||
audition.imagePath.prepend("/").prepend(cloudFrontHost),
|
||||
audition.status.eq(AuditionStatus.COMPLETED)
|
||||
)
|
||||
)
|
||||
.from(audition)
|
||||
.where(where)
|
||||
.orderBy(audition.status.desc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,4 @@ class AuditionService(
|
||||
roleList = roleList
|
||||
)
|
||||
}
|
||||
|
||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem> {
|
||||
return repository.getInProgressAuditionList(isAdult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService)
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||
pageable: Pageable
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getAuditionApplicantList(
|
||||
@@ -42,7 +42,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService)
|
||||
@RequestPart("request") requestString: String,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.applyAuditionRole(
|
||||
|
||||
@@ -47,11 +47,11 @@ class AuditionApplicantService(
|
||||
|
||||
@Transactional
|
||||
fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) {
|
||||
if (contentFile == null) throw SodaException(messageKey = "audition.applicant.content_file_required")
|
||||
if (contentFile == null) throw SodaException("녹음 파일을 확인해 주세요.")
|
||||
val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java)
|
||||
|
||||
val auditionRole = roleRepository.findByIdOrNull(id = request.roleId)
|
||||
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId(
|
||||
memberId = member.id!!,
|
||||
|
||||
@@ -17,7 +17,7 @@ class AuditionRoleController(private val service: AuditionRoleService) {
|
||||
@PathVariable id: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
||||
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||
|
||||
ApiResponse.ok(
|
||||
service.getAuditionRoleDetail(
|
||||
|
||||
@@ -11,7 +11,7 @@ class AuditionRoleService(
|
||||
) {
|
||||
fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse {
|
||||
val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
||||
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
val isAlreadyApplicant = applicantRepository.isAlreadyApplicant(
|
||||
auditionRoleId = auditionRoleId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user