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
|
indent_style = space
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
max_line_length = 130
|
max_line_length = 120
|
||||||
tab_width = 4
|
tab_width = 4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
|||||||
### Gradle Patch ###
|
### Gradle Patch ###
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
.kiro/
|
|
||||||
.junie
|
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
# 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"
|
val querydslVersion = "5.0.0"
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
@@ -70,9 +70,6 @@ dependencies {
|
|||||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
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")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
runtimeOnly("com.h2database:h2")
|
runtimeOnly("com.h2database:h2")
|
||||||
runtimeOnly("com.mysql:mysql-connector-j")
|
runtimeOnly("com.mysql:mysql-connector-j")
|
||||||
@@ -89,7 +86,7 @@ allOpen {
|
|||||||
tasks.withType<KotlinCompile> {
|
tasks.withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs += "-Xjsr305=strict"
|
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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -24,8 +22,6 @@ class AdminAuditionService(
|
|||||||
private val repository: AdminAuditionRepository,
|
private val repository: AdminAuditionRepository,
|
||||||
private val roleRepository: AdminAuditionRoleRepository,
|
private val roleRepository: AdminAuditionRoleRepository,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
private val langContext: LangContext,
|
|
||||||
private val messageSource: SodaMessageSource,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
@@ -48,7 +44,7 @@ class AdminAuditionService(
|
|||||||
fun updateAudition(image: MultipartFile?, requestString: String) {
|
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||||
val audition = repository.findByIdOrNull(id = request.id)
|
val audition = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
audition.title = request.title
|
audition.title = request.title
|
||||||
@@ -67,7 +63,7 @@ class AdminAuditionService(
|
|||||||
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||||
request.status == AuditionStatus.NOT_STARTED
|
request.status == AuditionStatus.NOT_STARTED
|
||||||
) {
|
) {
|
||||||
throw SodaException(messageKey = "admin.audition.status_cannot_revert")
|
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
audition.status = request.status
|
audition.status = request.status
|
||||||
@@ -92,14 +88,11 @@ class AdminAuditionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
||||||
val title = messageSource.getMessage("admin.audition.fcm.title.new", langContext.lang).orEmpty()
|
|
||||||
val messageTemplate = messageSource.getMessage("admin.audition.fcm.message.new", langContext.lang).orEmpty()
|
|
||||||
val message = String.format(messageTemplate, audition.title)
|
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||||
title = title,
|
title = "새로운 오디션 등록!",
|
||||||
message = message,
|
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
||||||
isAuth = audition.isAdult,
|
isAuth = audition.isAdult,
|
||||||
auditionId = audition.id ?: -1
|
auditionId = audition.id ?: -1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ data class CreateAuditionRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (title.isBlank()) {
|
if (title.isBlank()) {
|
||||||
throw SodaException(messageKey = "admin.audition.title_required")
|
throw SodaException("오디션 제목을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
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
|
@Transactional
|
||||||
fun deleteAuditionApplicant(id: Long) {
|
fun deleteAuditionApplicant(id: Long) {
|
||||||
val applicant = repository.findByIdOrNull(id)
|
val applicant = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
applicant.isActive = false
|
applicant.isActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class AdminAuditionRoleService(
|
|||||||
auditionScriptUrl = request.auditionScriptUrl
|
auditionScriptUrl = request.auditionScriptUrl
|
||||||
)
|
)
|
||||||
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
auditionRole.audition = audition
|
auditionRole.audition = audition
|
||||||
repository.save(auditionRole)
|
repository.save(auditionRole)
|
||||||
|
|
||||||
@@ -48,19 +48,15 @@ class AdminAuditionRoleService(
|
|||||||
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||||
val auditionRole = repository.findByIdOrNull(id = request.id)
|
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.audition.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
if (!request.name.isNullOrBlank()) {
|
if (!request.name.isNullOrBlank()) {
|
||||||
if (request.name.length < 2) {
|
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
||||||
throw SodaException(messageKey = "admin.audition.role.name_min_length")
|
|
||||||
}
|
|
||||||
auditionRole.name = request.name
|
auditionRole.name = request.name
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!request.information.isNullOrBlank()) {
|
if (!request.information.isNullOrBlank()) {
|
||||||
if (request.information.length < 10) {
|
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
||||||
throw SodaException(messageKey = "admin.audition.role.information_min_length")
|
|
||||||
}
|
|
||||||
auditionRole.information = request.information
|
auditionRole.information = request.information
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ data class CreateAuditionRoleRequest(
|
|||||||
) {
|
) {
|
||||||
init {
|
init {
|
||||||
if (auditionId < 0) {
|
if (auditionId < 0) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.audition_required")
|
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name.isBlank() || name.length < 2) {
|
if (name.isBlank() || name.length < 2) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.name_required")
|
throw SodaException("캐릭터명을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||||
throw SodaException(messageKey = "admin.audition.role.script_url_required")
|
throw SodaException("오디션 대본 URL을 입력하세요")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (information.isBlank() || information.length < 10) {
|
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 {
|
init {
|
||||||
if (id < 0) {
|
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(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -78,10 +75,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -148,10 +142,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(order.isActive.isTrue)
|
.where(order.isActive.isTrue)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
member.id,
|
member.id,
|
||||||
@@ -239,10 +230,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -263,10 +251,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -296,10 +281,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.room, liveRoom)
|
.innerJoin(useCan.room, liveRoom)
|
||||||
.innerJoin(liveRoom.member, member)
|
.innerJoin(liveRoom.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.createdAt.goe(startDate))
|
.and(useCan.createdAt.goe(startDate))
|
||||||
@@ -319,10 +301,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -352,10 +331,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(order.audioContent, audioContent)
|
.innerJoin(order.audioContent, audioContent)
|
||||||
.innerJoin(audioContent.member, member)
|
.innerJoin(audioContent.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
order.createdAt.goe(startDate)
|
order.createdAt.goe(startDate)
|
||||||
.and(order.createdAt.loe(endDate))
|
.and(order.createdAt.loe(endDate))
|
||||||
@@ -375,10 +351,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||||
@@ -409,10 +382,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||||
.innerJoin(creatorCommunity.member, member)
|
.innerJoin(creatorCommunity.member, member)
|
||||||
.leftJoin(creatorSettlementRatio)
|
.leftJoin(creatorSettlementRatio)
|
||||||
.on(
|
.on(member.id.eq(creatorSettlementRatio.member.id))
|
||||||
member.id.eq(creatorSettlementRatio.member.id)
|
|
||||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
)
|
|
||||||
.where(
|
.where(
|
||||||
useCan.isRefund.isFalse
|
useCan.isRefund.isFalse
|
||||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
.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.common.BaseEntity
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import java.time.LocalDateTime
|
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.FetchType
|
import javax.persistence.FetchType
|
||||||
import javax.persistence.JoinColumn
|
import javax.persistence.JoinColumn
|
||||||
@@ -10,29 +9,12 @@ import javax.persistence.OneToOne
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
data class CreatorSettlementRatio(
|
data class CreatorSettlementRatio(
|
||||||
var subsidy: Int,
|
val subsidy: Int,
|
||||||
var liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
var contentSettlementRatio: Int,
|
val contentSettlementRatio: Int,
|
||||||
var communitySettlementRatio: Int
|
val communitySettlementRatio: Int
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "member_id", nullable = false)
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
var member: Member? = null
|
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.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
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.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
@@ -28,14 +27,4 @@ class CreatorSettlementRatioController(private val service: CreatorSettlementRat
|
|||||||
limit = pageable.pageSize.toLong()
|
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 :
|
interface CreatorSettlementRatioRepository :
|
||||||
JpaRepository<CreatorSettlementRatio, Long>,
|
JpaRepository<CreatorSettlementRatio, Long>,
|
||||||
CreatorSettlementRatioQueryRepository {
|
CreatorSettlementRatioQueryRepository
|
||||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreatorSettlementRatioQueryRepository {
|
interface CreatorSettlementRatioQueryRepository {
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||||
@@ -23,7 +21,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetCreatorSettlementRatioItem(
|
QGetCreatorSettlementRatioItem(
|
||||||
member.id,
|
|
||||||
member.nickname,
|
member.nickname,
|
||||||
creatorSettlementRatio.subsidy,
|
creatorSettlementRatio.subsidy,
|
||||||
creatorSettlementRatio.liveSettlementRatio,
|
creatorSettlementRatio.liveSettlementRatio,
|
||||||
@@ -33,7 +30,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
)
|
)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.innerJoin(creatorSettlementRatio.member, member)
|
.innerJoin(creatorSettlementRatio.member, member)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.orderBy(creatorSettlementRatio.id.asc())
|
.orderBy(creatorSettlementRatio.id.asc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -44,7 +40,6 @@ class CreatorSettlementRatioQueryRepositoryImpl(
|
|||||||
return queryFactory
|
return queryFactory
|
||||||
.select(creatorSettlementRatio.id)
|
.select(creatorSettlementRatio.id)
|
||||||
.from(creatorSettlementRatio)
|
.from(creatorSettlementRatio)
|
||||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
|
||||||
.fetch()
|
.fetch()
|
||||||
.size
|
.size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,59 +14,19 @@ class CreatorSettlementRatioService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||||
|
val creatorSettlementRatio = request.toEntity()
|
||||||
|
|
||||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||||
?: throw SodaException(messageKey = "admin.settlement_ratio.invalid_creator")
|
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||||
|
|
||||||
if (creator.role != MemberRole.CREATOR) {
|
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
|
creatorSettlementRatio.member = creator
|
||||||
repository.save(creatorSettlementRatio)
|
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)
|
@Transactional(readOnly = true)
|
||||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ data class GetCreatorSettlementRatioResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||||
val memberId: Long,
|
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val subsidy: Int,
|
val subsidy: Int,
|
||||||
val liveSettlementRatio: Int,
|
val liveSettlementRatio: Int,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
data class AdminCanChargeRequest(
|
data class AdminCanChargeRequest(
|
||||||
val memberIds: List<Long>,
|
val memberId: Long,
|
||||||
val method: String,
|
val method: String,
|
||||||
val can: Int
|
val can: Int
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.can.CanResponse
|
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
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.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
@RequestMapping("/admin/can")
|
@RequestMapping("/admin/can")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
class AdminCanController(private val service: AdminCanService) {
|
class AdminCanController(private val service: AdminCanService) {
|
||||||
@GetMapping
|
|
||||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
|
||||||
return ApiResponse.ok(service.getCans())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
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.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.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.stereotype.Repository
|
|
||||||
|
|
||||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package kr.co.vividnext.sodalive.admin.can
|
|||||||
import kr.co.vividnext.sodalive.can.Can
|
import kr.co.vividnext.sodalive.can.Can
|
||||||
import kr.co.vividnext.sodalive.can.CanStatus
|
import kr.co.vividnext.sodalive.can.CanStatus
|
||||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class AdminCanRequest(
|
data class AdminCanRequest(
|
||||||
val can: Int,
|
val can: Int,
|
||||||
val rewardCan: Int,
|
val rewardCan: Int,
|
||||||
val price: BigDecimal,
|
val price: Int
|
||||||
val currency: String
|
|
||||||
) {
|
) {
|
||||||
fun toEntity(): Can {
|
fun toEntity(): Can {
|
||||||
var title = "${can.moneyFormat()} 캔"
|
var title = "${can.moneyFormat()} 캔"
|
||||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
|||||||
can = can,
|
can = can,
|
||||||
rewardCan = rewardCan,
|
rewardCan = rewardCan,
|
||||||
price = price,
|
price = price,
|
||||||
currency = currency,
|
|
||||||
status = CanStatus.SALE
|
status = CanStatus.SALE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.can
|
package kr.co.vividnext.sodalive.admin.can
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
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.CanStatus
|
||||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||||
@@ -21,10 +20,6 @@ class AdminCanService(
|
|||||||
private val chargeRepository: ChargeRepository,
|
private val chargeRepository: ChargeRepository,
|
||||||
private val memberRepository: AdminMemberRepository
|
private val memberRepository: AdminMemberRepository
|
||||||
) {
|
) {
|
||||||
fun getCans(): List<CanResponse> {
|
|
||||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun saveCan(request: AdminCanRequest) {
|
fun saveCan(request: AdminCanRequest) {
|
||||||
repository.save(request.toEntity())
|
repository.save(request.toEntity())
|
||||||
@@ -33,34 +28,29 @@ class AdminCanService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteCan(id: Long) {
|
fun deleteCan(id: Long) {
|
||||||
val can = repository.findByIdOrNull(id)
|
val can = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
can.status = CanStatus.END_OF_SALE
|
can.status = CanStatus.END_OF_SALE
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun charge(request: AdminCanChargeRequest) {
|
fun charge(request: AdminCanChargeRequest) {
|
||||||
if (request.can <= 0) throw SodaException(messageKey = "admin.can.min_amount")
|
val member = memberRepository.findByIdOrNull(request.memberId)
|
||||||
if (request.method.isBlank()) throw SodaException(messageKey = "admin.can.method_required")
|
?: throw SodaException("잘못된 회원번호 입니다.")
|
||||||
|
|
||||||
val ids = request.memberIds.distinct()
|
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
|
||||||
if (ids.isEmpty()) throw SodaException(messageKey = "admin.can.member_ids_required")
|
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
|
||||||
|
|
||||||
val members = memberRepository.findAllById(ids).toList()
|
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
||||||
if (members.size != ids.size) throw SodaException(messageKey = "admin.can.invalid_member_ids")
|
charge.title = "${request.can.moneyFormat()} 캔"
|
||||||
|
charge.member = member
|
||||||
|
|
||||||
members.forEach { member ->
|
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
||||||
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
|
payment.method = request.method
|
||||||
charge.title = "${request.can.moneyFormat()} 캔"
|
charge.payment = payment
|
||||||
charge.member = member
|
|
||||||
|
|
||||||
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
|
chargeRepository.save(charge)
|
||||||
payment.method = request.method
|
|
||||||
charge.payment = payment
|
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
member.pgRewardCan += charge.rewardCan
|
||||||
|
|
||||||
member.pgRewardCan += charge.rewardCan
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
|||||||
@GetMapping("/detail")
|
@GetMapping("/detail")
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
@RequestParam startDateStr: String,
|
@RequestParam startDateStr: String,
|
||||||
@RequestParam paymentGateway: PaymentGateway,
|
@RequestParam paymentGateway: PaymentGateway
|
||||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.BooleanBuilder
|
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||||
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
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(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
Expressions.dateTimeTemplate(
|
Expressions.dateTimeTemplate(
|
||||||
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d"
|
"%Y-%m-%d"
|
||||||
)
|
)
|
||||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
QGetChargeStatusResponse(
|
QGetChargeStatusQueryDto(
|
||||||
formattedDate,
|
formattedDate,
|
||||||
payment.price.sum(),
|
payment.price.sum(),
|
||||||
|
can1.price.sum(),
|
||||||
payment.id.count(),
|
payment.id.count(),
|
||||||
payment.paymentGateway.stringValue(),
|
payment.paymentGateway
|
||||||
currency.coalesce("KRW")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(payment)
|
.from(payment)
|
||||||
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||||
)
|
)
|
||||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
.groupBy(formattedDate, payment.paymentGateway)
|
||||||
.orderBy(formattedDate.desc())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.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(
|
fun getChargeStatusDetail(
|
||||||
startDate: LocalDateTime,
|
startDate: LocalDateTime,
|
||||||
endDate: LocalDateTime,
|
endDate: LocalDateTime,
|
||||||
paymentGateway: PaymentGateway,
|
paymentGateway: PaymentGateway
|
||||||
currency: String? = null
|
|
||||||
): List<GetChargeStatusDetailQueryDto> {
|
): List<GetChargeStatusDetailQueryDto> {
|
||||||
val formattedDate = Expressions.stringTemplate(
|
val formattedDate = Expressions.stringTemplate(
|
||||||
"DATE_FORMAT({0}, {1})",
|
"DATE_FORMAT({0}, {1})",
|
||||||
@@ -100,20 +67,6 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
),
|
),
|
||||||
"%Y-%m-%d %H:%i:%s"
|
"%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
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
member.nickname,
|
member.nickname,
|
||||||
payment.method.coalesce(""),
|
payment.method.coalesce(""),
|
||||||
payment.price,
|
payment.price,
|
||||||
currencyExpr,
|
can1.price,
|
||||||
|
payment.locale.coalesce(""),
|
||||||
formattedDate
|
formattedDate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
|||||||
.innerJoin(charge.member, member)
|
.innerJoin(charge.member, member)
|
||||||
.innerJoin(charge.payment, payment)
|
.innerJoin(charge.payment, payment)
|
||||||
.leftJoin(charge.can, can1)
|
.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())
|
.orderBy(formattedDate.desc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
var totalChargeAmount = 0
|
||||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
var totalChargeCount = 0L
|
||||||
chargeStatusList.addAll(0, summaryRows)
|
|
||||||
|
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()
|
return chargeStatusList.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChargeStatusDetail(
|
fun getChargeStatusDetail(
|
||||||
startDateStr: String,
|
startDateStr: String,
|
||||||
paymentGateway: PaymentGateway,
|
paymentGateway: PaymentGateway
|
||||||
currency: String? = null
|
|
||||||
): List<GetChargeStatusDetailResponse> {
|
): List<GetChargeStatusDetailResponse> {
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||||
|
.asSequence()
|
||||||
.map {
|
.map {
|
||||||
GetChargeStatusDetailResponse(
|
GetChargeStatusDetailResponse(
|
||||||
memberId = it.memberId,
|
memberId = it.memberId,
|
||||||
nickname = it.nickname,
|
nickname = it.nickname,
|
||||||
method = it.method,
|
method = it.method,
|
||||||
amount = it.amount,
|
amount = it.appleChargeAmount.toInt(),
|
||||||
locale = it.locale,
|
locale = it.locale,
|
||||||
datetime = it.datetime
|
datetime = it.datetime
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: BigDecimal,
|
val appleChargeAmount: Double,
|
||||||
|
val pgChargeAmount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: String
|
val datetime: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package kr.co.vividnext.sodalive.admin.charge
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusDetailResponse(
|
data class GetChargeStatusDetailResponse(
|
||||||
val memberId: Long,
|
val memberId: Long,
|
||||||
val nickname: String,
|
val nickname: String,
|
||||||
val method: String,
|
val method: String,
|
||||||
val amount: BigDecimal,
|
val amount: Int,
|
||||||
val locale: String,
|
val locale: String,
|
||||||
val datetime: 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
|
package kr.co.vividnext.sodalive.admin.charge
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
data class GetChargeStatusResponse(
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
|
||||||
val date: String,
|
val date: String,
|
||||||
val chargeAmount: BigDecimal,
|
val chargeAmount: Int,
|
||||||
val chargeCount: Long,
|
val chargeCount: Long,
|
||||||
val pg: String,
|
val pg: String
|
||||||
val currency: 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
|
audioContent.duration.isNotNull
|
||||||
.and(audioContent.member.isNotNull)
|
.and(audioContent.member.isNotNull)
|
||||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||||
.and(audioContentHashTag.isActive.isTrue)
|
|
||||||
)
|
)
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ class AdminContentService(
|
|||||||
searchWord: String,
|
searchWord: String,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): GetAdminContentListResponse {
|
): GetAdminContentListResponse {
|
||||||
if (searchWord.length < 2) {
|
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||||
throw SodaException(messageKey = "admin.content.search_word_min_length")
|
|
||||||
}
|
|
||||||
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
||||||
val audioContentAndThemeList = repository.getAudioContentList(
|
val audioContentAndThemeList = repository.getAudioContentList(
|
||||||
status = status,
|
status = status,
|
||||||
@@ -84,7 +82,7 @@ class AdminContentService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
fun updateAudioContent(request: UpdateAdminContentRequest) {
|
||||||
val audioContent = repository.findByIdOrNull(id = request.id)
|
val audioContent = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "admin.content.not_found")
|
?: throw SodaException("없는 콘텐츠 입니다.")
|
||||||
|
|
||||||
if (request.isDefaultCoverImage) {
|
if (request.isDefaultCoverImage) {
|
||||||
audioContent.coverImage = "`profile/default_profile.png`"
|
audioContent.coverImage = "`profile/default_profile.png`"
|
||||||
|
|||||||
@@ -33,19 +33,19 @@ class AdminContentBannerService(
|
|||||||
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
fun createAudioContentMainBanner(image: MultipartFile, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateContentBannerRequest::class.java)
|
||||||
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
if (request.type == AudioContentBannerType.CREATOR && request.creatorId == null) {
|
||||||
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
throw SodaException("크리에이터를 선택하세요.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
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) {
|
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) {
|
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) {
|
val event = if (request.eventId != null && request.eventId > 0) {
|
||||||
@@ -94,7 +94,7 @@ class AdminContentBannerService(
|
|||||||
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
fun updateAudioContentMainBanner(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateContentBannerRequest::class.java)
|
||||||
val audioContentBanner = repository.findByIdOrNull(request.id)
|
val audioContentBanner = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
val fileName = generateFileName()
|
val fileName = generateFileName()
|
||||||
@@ -124,22 +124,22 @@ class AdminContentBannerService(
|
|||||||
AudioContentBannerType.EVENT -> {
|
AudioContentBannerType.EVENT -> {
|
||||||
if (request.eventId != null) {
|
if (request.eventId != null) {
|
||||||
val event = eventRepository.findByIdOrNull(request.eventId)
|
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||||
?: throw SodaException(messageKey = "admin.content.banner.event_required")
|
?: throw SodaException("이벤트를 선택하세요.")
|
||||||
|
|
||||||
audioContentBanner.event = event
|
audioContentBanner.event = event
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "admin.content.banner.event_required")
|
throw SodaException("이벤트를 선택하세요.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContentBannerType.CREATOR -> {
|
AudioContentBannerType.CREATOR -> {
|
||||||
if (request.creatorId != null) {
|
if (request.creatorId != null) {
|
||||||
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||||
?: throw SodaException(messageKey = "admin.content.banner.creator_required")
|
?: throw SodaException("크리에이터를 선택하세요.")
|
||||||
|
|
||||||
audioContentBanner.creator = creator
|
audioContentBanner.creator = creator
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "admin.content.banner.creator_required")
|
throw SodaException("크리에이터를 선택하세요.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,18 +147,18 @@ class AdminContentBannerService(
|
|||||||
if (request.link != null) {
|
if (request.link != null) {
|
||||||
audioContentBanner.link = request.link
|
audioContentBanner.link = request.link
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "admin.content.banner.link_required")
|
throw SodaException("링크 url을 입력하세요.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioContentBannerType.SERIES -> {
|
AudioContentBannerType.SERIES -> {
|
||||||
if (request.seriesId != null) {
|
if (request.seriesId != null) {
|
||||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
?: throw SodaException(messageKey = "admin.content.banner.series_required")
|
?: throw SodaException("시리즈를 선택하세요.")
|
||||||
|
|
||||||
audioContentBanner.series = series
|
audioContentBanner.series = series
|
||||||
} else {
|
} else {
|
||||||
throw SodaException(messageKey = "admin.content.banner.series_required")
|
throw SodaException("시리즈를 선택하세요.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AdminContentCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun createContentCuration(request: CreateContentCurationRequest) {
|
fun createContentCuration(request: CreateContentCurationRequest) {
|
||||||
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
val curation = AudioContentCuration(
|
val curation = AudioContentCuration(
|
||||||
title = request.title,
|
title = request.title,
|
||||||
@@ -37,7 +37,7 @@ class AdminContentCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
fun updateContentCuration(request: UpdateContentCurationRequest) {
|
||||||
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
val audioContentCuration = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
audioContentCuration.title = request.title
|
audioContentCuration.title = request.title
|
||||||
@@ -85,7 +85,7 @@ class AdminContentCurationService(
|
|||||||
|
|
||||||
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
||||||
val curation = repository.findByIdOrNull(curationId)
|
val curation = repository.findByIdOrNull(curationId)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
return if (curation.isSeries) {
|
return if (curation.isSeries) {
|
||||||
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
||||||
@@ -106,7 +106,7 @@ class AdminContentCurationService(
|
|||||||
fun addItemToCuration(request: AddItemToCurationRequest) {
|
fun addItemToCuration(request: AddItemToCurationRequest) {
|
||||||
// 큐레이션 조회
|
// 큐레이션 조회
|
||||||
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (audioContentCuration.isSeries) {
|
if (audioContentCuration.isSeries) {
|
||||||
request.itemIdList.forEach { seriesId ->
|
request.itemIdList.forEach { seriesId ->
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class AdminHashTagCurationService(
|
|||||||
val isExists = repository.isExistsTag(tag = tag)
|
val isExists = repository.isExistsTag(tag = tag)
|
||||||
|
|
||||||
if (isExists) {
|
if (isExists) {
|
||||||
throw SodaException(messageKey = "admin.content.hash_tag.already_registered")
|
throw SodaException("이미 등록된 태그 입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.save(
|
repository.save(
|
||||||
@@ -42,7 +42,7 @@ class AdminHashTagCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
||||||
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (request.tag != null) {
|
if (request.tag != null) {
|
||||||
var tag = request.tag.trim()
|
var tag = request.tag.trim()
|
||||||
@@ -88,7 +88,7 @@ class AdminHashTagCurationService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
||||||
val curation = repository.findByIdOrNull(id = request.curationId)
|
val curation = repository.findByIdOrNull(id = request.curationId)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
request.itemIdList.forEach { contentId ->
|
request.itemIdList.forEach { contentId ->
|
||||||
val audioContent = audioContentRepository.findByIdAndActive(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.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
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.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
@@ -21,9 +19,4 @@ class AdminContentSeriesController(private val service: AdminContentSeriesServic
|
|||||||
fun searchSeriesList(
|
fun searchSeriesList(
|
||||||
@RequestParam(value = "search_word") searchWord: String
|
@RequestParam(value = "search_word") searchWord: String
|
||||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
) = 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
|
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.data.domain.Pageable
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AdminContentSeriesService(
|
class AdminContentSeriesService(private val repository: AdminContentSeriesRepository) {
|
||||||
private val repository: AdminContentSeriesRepository,
|
|
||||||
private val genreRepository: AdminContentSeriesGenreRepository
|
|
||||||
) {
|
|
||||||
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
fun getSeriesList(pageable: Pageable): GetAdminSeriesListResponse {
|
||||||
val totalCount = repository.getSeriesTotalCount()
|
val totalCount = repository.getSeriesTotalCount()
|
||||||
val items = repository.getSeriesList(
|
val items = repository.getSeriesList(
|
||||||
@@ -19,53 +12,10 @@ class AdminContentSeriesService(
|
|||||||
limit = pageable.pageSize.toLong()
|
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)
|
return GetAdminSeriesListResponse(totalCount, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||||
return repository.searchSeriesList(searchWord)
|
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
|
package kr.co.vividnext.sodalive.admin.content.series
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
|
|
||||||
|
|
||||||
data class GetAdminSeriesListResponse(
|
data class GetAdminSeriesListResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -18,10 +17,7 @@ data class GetAdminSeriesListItem @QueryProjection constructor(
|
|||||||
val numberOfWorks: Long,
|
val numberOfWorks: Long,
|
||||||
val state: String,
|
val state: String,
|
||||||
val isAdult: Boolean
|
val isAdult: Boolean
|
||||||
) {
|
)
|
||||||
var publishedDaysOfWeek: List<SeriesPublishedDaysOfWeek> = emptyList()
|
|
||||||
var isOriginal: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
data class GetAdminSearchSeriesListItem @QueryProjection constructor(
|
||||||
val id: Long,
|
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 {
|
interface AdminContentSeriesGenreQueryRepository {
|
||||||
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
fun getSeriesGenreList(): List<GetSeriesGenreListResponse>
|
||||||
fun findActiveSeriesGenreById(id: Long): SeriesGenre?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminContentSeriesGenreQueryRepositoryImpl(
|
class AdminContentSeriesGenreQueryRepositoryImpl(
|
||||||
@@ -22,14 +21,4 @@ class AdminContentSeriesGenreQueryRepositoryImpl(
|
|||||||
.orderBy(seriesGenre.orders.asc())
|
.orderBy(seriesGenre.orders.asc())
|
||||||
.fetch()
|
.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
|
@Transactional
|
||||||
fun modifySeriesGenre(request: ModifySeriesGenreRequest) {
|
fun modifySeriesGenre(request: ModifySeriesGenreRequest) {
|
||||||
if (request.genre == null && request.isAdult == null && request.isActive == null) {
|
if (request.genre == null && request.isAdult == null && request.isActive == null) {
|
||||||
throw SodaException(messageKey = "admin.content.series.genre.no_changes")
|
throw SodaException("변경사항이 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val seriesGenre = repository.findByIdOrNull(id = request.id)
|
val seriesGenre = repository.findByIdOrNull(id = request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (request.genre != null) {
|
if (request.genre != null) {
|
||||||
seriesGenre.genre = request.genre
|
seriesGenre.genre = request.genre
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class AdminRecommendSeriesService(
|
|||||||
fun createRecommendSeries(image: MultipartFile, requestString: String) {
|
fun createRecommendSeries(image: MultipartFile, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateRecommendSeriesRequest::class.java)
|
||||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
val recommendSeries = RecommendSeries(isFree = request.isFree)
|
val recommendSeries = RecommendSeries(isFree = request.isFree)
|
||||||
recommendSeries.series = series
|
recommendSeries.series = series
|
||||||
@@ -49,7 +49,7 @@ class AdminRecommendSeriesService(
|
|||||||
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
|
fun updateRecommendSeries(image: MultipartFile?, requestString: String) {
|
||||||
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
|
val request = objectMapper.readValue(requestString, UpdateRecommendSeriesRequest::class.java)
|
||||||
val recommendSeries = repository.findByIdOrNull(request.id)
|
val recommendSeries = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
val fileName = generateFileName()
|
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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
|
||||||
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
|
||||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
|
||||||
import org.springframework.data.repository.findByIdOrNull
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -21,8 +18,6 @@ class AdminContentThemeService(
|
|||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val repository: AdminContentThemeRepository,
|
private val repository: AdminContentThemeRepository,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
) {
|
) {
|
||||||
@@ -42,27 +37,17 @@ class AdminContentThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createTheme(theme: String, imagePath: String) {
|
fun createTheme(theme: String, imagePath: String) {
|
||||||
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||||
|
|
||||||
applicationEventPublisher.publishEvent(
|
|
||||||
LanguageTranslationEvent(
|
|
||||||
id = savedTheme.id!!,
|
|
||||||
targetType = LanguageTranslationTargetType.CONTENT_THEME,
|
|
||||||
waitTransactionCommit = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||||
repository.findIdByTheme(request.theme)?.let {
|
repository.findIdByTheme(request.theme)?.let { throw SodaException("이미 등록된 테마 입니다.") }
|
||||||
throw SodaException(messageKey = "admin.content.theme.already_registered")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTheme(id: Long) {
|
fun deleteTheme(id: Long) {
|
||||||
val theme = repository.findByIdOrNull(id)
|
val theme = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
theme.theme = "${theme.theme}_deleted"
|
theme.theme = "${theme.theme}_deleted"
|
||||||
theme.isActive = false
|
theme.isActive = false
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ class AdminEventBannerService(
|
|||||||
startDateString: String,
|
startDateString: String,
|
||||||
endDateString: String
|
endDateString: String
|
||||||
): Long {
|
): Long {
|
||||||
if (detail == null && link.isNullOrBlank()) {
|
if (detail == null && link.isNullOrBlank()) throw SodaException("상세이미지 혹은 링크를 등록하세요")
|
||||||
throw SodaException(messageKey = "admin.event.banner.detail_or_link_required")
|
|
||||||
}
|
|
||||||
|
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||||
@@ -104,7 +102,7 @@ class AdminEventBannerService(
|
|||||||
event.detailImage = detailImagePath
|
event.detailImage = detailImagePath
|
||||||
event.popupImage = popupImagePath
|
event.popupImage = popupImagePath
|
||||||
|
|
||||||
return event.id ?: throw SodaException(messageKey = "admin.event.banner.create_failed")
|
return event.id ?: throw SodaException("이벤트 등록을 하지 못했습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -120,10 +118,10 @@ class AdminEventBannerService(
|
|||||||
startDateString: String? = null,
|
startDateString: String? = null,
|
||||||
endDateString: 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)
|
val event = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (thumbnail != null) {
|
if (thumbnail != null) {
|
||||||
val metadata = ObjectMetadata()
|
val metadata = ObjectMetadata()
|
||||||
@@ -192,9 +190,9 @@ class AdminEventBannerService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun delete(id: Long) {
|
fun delete(id: Long) {
|
||||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
if (id <= 0) throw SodaException("잘못된 요청입니다.")
|
||||||
val event = repository.findByIdOrNull(id)
|
val event = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
event.isActive = false
|
event.isActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class AdminChargeEventService(private val repository: AdminChargeEventRepository
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
|
fun modifyChargeEvent(request: ModifyChargeEventRequest) {
|
||||||
val chargeEvent = repository.findByIdOrNull(request.id)
|
val chargeEvent = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "admin.charge_event.not_found_retry")
|
?: throw SodaException("해당하는 충전이벤트가 없습니다\n다시 시도해 주세요.")
|
||||||
|
|
||||||
if (request.title != null) {
|
if (request.title != null) {
|
||||||
chargeEvent.title = request.title
|
chargeEvent.title = request.title
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class AdminExplorerService(
|
|||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
|
fun createExplorerSection(request: CreateExplorerSectionRequest): Long {
|
||||||
if (request.title.isBlank()) throw SodaException(messageKey = "admin.explorer.title_required")
|
if (request.title.isBlank()) throw SodaException("제목을 입력하세요.")
|
||||||
|
|
||||||
val findExplorerSection = repository.findByTitle(request.title)
|
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)
|
val explorerSection = ExplorerSection(title = request.title, isAdult = request.isAdult)
|
||||||
explorerSection.coloredTitle = request.coloredTitle
|
explorerSection.coloredTitle = request.coloredTitle
|
||||||
@@ -37,7 +37,7 @@ class AdminExplorerService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tags.size <= 0) throw SodaException(messageKey = "admin.explorer.tags_required")
|
if (tags.size <= 0) throw SodaException("관심사를 선택하세요.")
|
||||||
explorerSection.tags = tags
|
explorerSection.tags = tags
|
||||||
|
|
||||||
return repository.save(explorerSection).id!!
|
return repository.save(explorerSection).id!!
|
||||||
@@ -53,14 +53,14 @@ class AdminExplorerService(
|
|||||||
request.coloredTitle == null &&
|
request.coloredTitle == null &&
|
||||||
request.isActive == null
|
request.isActive == null
|
||||||
) {
|
) {
|
||||||
throw SodaException(messageKey = "admin.explorer.no_changes")
|
throw SodaException("변경사항이 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val explorerSection = repository.findByIdOrNull(request.id)
|
val explorerSection = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "admin.explorer.section_not_found")
|
?: throw SodaException("해당하는 섹션이 없습니다.")
|
||||||
|
|
||||||
if (request.title != null) {
|
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
|
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) {
|
if (tags != explorerSection.tags) {
|
||||||
explorerSection.tags.clear()
|
explorerSection.tags.clear()
|
||||||
explorerSection.tags.addAll(tags)
|
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.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|
||||||
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBanner
|
||||||
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
|
import kr.co.vividnext.sodalive.live.recommend.RecommendLiveCreatorBannerRepository
|
||||||
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
|
||||||
@@ -51,8 +49,6 @@ class AdminLiveService(
|
|||||||
private val canRepository: CanRepository,
|
private val canRepository: CanRepository,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
private val messageSource: SodaMessageSource,
|
|
||||||
private val langContext: LangContext,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String,
|
private val bucket: String,
|
||||||
@@ -122,10 +118,10 @@ class AdminLiveService(
|
|||||||
endDateString: String,
|
endDateString: String,
|
||||||
isAdult: Boolean
|
isAdult: Boolean
|
||||||
): Long {
|
): Long {
|
||||||
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
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 dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||||
@@ -138,15 +134,15 @@ class AdminLiveService(
|
|||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (startDate < nowDate) throw SodaException(messageKey = "admin.live.start_after_now")
|
if (startDate < nowDate) throw SodaException("노출 시작일은 현재시간 이후로 설정하셔야 합니다.")
|
||||||
|
|
||||||
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
|
val endDate = LocalDateTime.parse(endDateString, dateTimeFormatter)
|
||||||
.atZone(ZoneId.of("Asia/Seoul"))
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (endDate < nowDate) throw SodaException(messageKey = "admin.live.end_after_now")
|
if (endDate < nowDate) throw SodaException("노출 종료일은 현재시간 이후로 설정하셔야 합니다.")
|
||||||
if (endDate <= startDate) throw SodaException(messageKey = "admin.live.start_before_end")
|
if (endDate <= startDate) throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||||
|
|
||||||
val recommendCreatorBanner = RecommendLiveCreatorBanner(
|
val recommendCreatorBanner = RecommendLiveCreatorBanner(
|
||||||
startDate = startDate,
|
startDate = startDate,
|
||||||
@@ -180,13 +176,13 @@ class AdminLiveService(
|
|||||||
isAdult: Boolean?
|
isAdult: Boolean?
|
||||||
) {
|
) {
|
||||||
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
|
val recommendCreatorBanner = recommendCreatorBannerRepository.findByIdOrNull(recommendCreatorBannerId)
|
||||||
?: throw SodaException(messageKey = "admin.live.recommend_not_found_retry")
|
?: throw SodaException("해당하는 추천라이브가 없습니다. 다시 확인해 주세요.")
|
||||||
|
|
||||||
if (creatorId != null) {
|
if (creatorId != null) {
|
||||||
if (creatorId < 1) throw SodaException(messageKey = "admin.live.creator_required")
|
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||||
?: throw SodaException(messageKey = "admin.live.creator_required")
|
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||||
|
|
||||||
recommendCreatorBanner.creator = creator
|
recommendCreatorBanner.creator = creator
|
||||||
}
|
}
|
||||||
@@ -222,13 +218,13 @@ class AdminLiveService(
|
|||||||
|
|
||||||
if (endDate != null) {
|
if (endDate != null) {
|
||||||
if (endDate <= startDate) {
|
if (endDate <= startDate) {
|
||||||
throw SodaException(messageKey = "admin.live.start_before_end")
|
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendCreatorBanner.endDate = endDate
|
recommendCreatorBanner.endDate = endDate
|
||||||
} else {
|
} else {
|
||||||
if (recommendCreatorBanner.endDate <= startDate) {
|
if (recommendCreatorBanner.endDate <= startDate) {
|
||||||
throw SodaException(messageKey = "admin.live.start_before_end")
|
throw SodaException("노출 시작일은 노출 종료일 이전으로 설정하셔야 합니다.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +237,7 @@ class AdminLiveService(
|
|||||||
.toLocalDateTime()
|
.toLocalDateTime()
|
||||||
|
|
||||||
if (endDate <= recommendCreatorBanner.startDate) {
|
if (endDate <= recommendCreatorBanner.startDate) {
|
||||||
throw SodaException(messageKey = "admin.live.end_after_start")
|
throw SodaException("노출 종료일은 노출 시작일 이후로 설정하셔야 합니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendCreatorBanner.endDate = endDate
|
recommendCreatorBanner.endDate = endDate
|
||||||
@@ -270,10 +266,7 @@ class AdminLiveService(
|
|||||||
for (room in findRoomList) {
|
for (room in findRoomList) {
|
||||||
room.isActive = false
|
room.isActive = false
|
||||||
|
|
||||||
val cancelReason = messageSource
|
val roomCancel = LiveRoomCancel("관리자에 의한 취소 - 노쇼")
|
||||||
.getMessage("admin.live.cancel_reason.no_show", langContext.lang)
|
|
||||||
.orEmpty()
|
|
||||||
val roomCancel = LiveRoomCancel(cancelReason)
|
|
||||||
roomCancel.room = room
|
roomCancel.room = room
|
||||||
roomCancelRepository.save(roomCancel)
|
roomCancelRepository.save(roomCancel)
|
||||||
|
|
||||||
@@ -293,10 +286,7 @@ class AdminLiveService(
|
|||||||
it.status = UseCanCalculateStatus.REFUND
|
it.status = UseCanCalculateStatus.REFUND
|
||||||
|
|
||||||
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
val charge = Charge(0, it.can, status = ChargeStatus.REFUND_CHARGE)
|
||||||
val canTitleTemplate = messageSource
|
charge.title = "${it.can} 캔"
|
||||||
.getMessage("live.room.can_title", langContext.lang)
|
|
||||||
.orEmpty()
|
|
||||||
charge.title = String.format(canTitleTemplate, it.can)
|
|
||||||
charge.useCan = useCan
|
charge.useCan = useCan
|
||||||
|
|
||||||
when (it.paymentGateway) {
|
when (it.paymentGateway) {
|
||||||
@@ -310,9 +300,7 @@ class AdminLiveService(
|
|||||||
status = PaymentStatus.COMPLETE,
|
status = PaymentStatus.COMPLETE,
|
||||||
paymentGateway = it.paymentGateway
|
paymentGateway = it.paymentGateway
|
||||||
)
|
)
|
||||||
payment.method = messageSource
|
payment.method = "환불"
|
||||||
.getMessage("live.room.refund_method", langContext.lang)
|
|
||||||
.orEmpty()
|
|
||||||
charge.payment = payment
|
charge.payment = payment
|
||||||
|
|
||||||
chargeRepository.save(charge)
|
chargeRepository.save(charge)
|
||||||
@@ -325,15 +313,11 @@ class AdminLiveService(
|
|||||||
reservationRepository.cancelReservation(roomId = room.id!!)
|
reservationRepository.cancelReservation(roomId = room.id!!)
|
||||||
|
|
||||||
// 라이브 취소 푸시 발송
|
// 라이브 취소 푸시 발송
|
||||||
val cancelMessageTemplate = messageSource
|
|
||||||
.getMessage("live.room.fcm.message.canceled", langContext.lang)
|
|
||||||
.orEmpty()
|
|
||||||
val cancelMessage = String.format(cancelMessageTemplate, room.title)
|
|
||||||
applicationEventPublisher.publishEvent(
|
applicationEventPublisher.publishEvent(
|
||||||
FcmEvent(
|
FcmEvent(
|
||||||
type = FcmEventType.CANCEL_LIVE,
|
type = FcmEventType.CANCEL_LIVE,
|
||||||
title = room.member!!.nickname,
|
title = room.member!!.nickname,
|
||||||
message = cancelMessage,
|
message = "라이브 취소 : ${room.title}",
|
||||||
recipientsMap = pushTokenListMap
|
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.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|
||||||
import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType
|
import kr.co.vividnext.sodalive.live.signature.SignatureCanSortType
|
||||||
import org.springframework.data.domain.Pageable
|
import org.springframework.data.domain.Pageable
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
@@ -18,11 +16,7 @@ import org.springframework.web.multipart.MultipartFile
|
|||||||
@RestController
|
@RestController
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@RequestMapping("/admin/live/signature-can")
|
@RequestMapping("/admin/live/signature-can")
|
||||||
class AdminSignatureCanController(
|
class AdminSignatureCanController(private val service: AdminSignatureCanService) {
|
||||||
private val service: AdminSignatureCanService,
|
|
||||||
private val messageSource: SodaMessageSource,
|
|
||||||
private val langContext: LangContext
|
|
||||||
) {
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun getSignatureCanList(
|
fun getSignatureCanList(
|
||||||
pageable: Pageable,
|
pageable: Pageable,
|
||||||
@@ -38,7 +32,7 @@ class AdminSignatureCanController(
|
|||||||
@RequestParam("isAdult", required = false) isAdult: Boolean = false
|
@RequestParam("isAdult", required = false) isAdult: Boolean = false
|
||||||
) = ApiResponse.ok(
|
) = ApiResponse.ok(
|
||||||
service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult),
|
service.createSignatureCan(can = can, time = time, creatorId = creatorId, image = image, isAdult = isAdult),
|
||||||
messageSource.getMessage("admin.signature_can.created", langContext.lang)
|
"등록되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
@PutMapping
|
@PutMapping
|
||||||
@@ -51,7 +45,7 @@ class AdminSignatureCanController(
|
|||||||
@RequestParam("isAdult", required = false) isAdult: Boolean?
|
@RequestParam("isAdult", required = false) isAdult: Boolean?
|
||||||
) = run {
|
) = run {
|
||||||
if (can == null && time == null && image == null && isActive == null && isAdult == null) {
|
if (can == null && time == null && image == null && isActive == null && isAdult == null) {
|
||||||
throw SodaException(messageKey = "admin.signature_can.no_changes")
|
throw SodaException("변경사항이 없습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
@@ -63,7 +57,7 @@ class AdminSignatureCanController(
|
|||||||
isActive = isActive,
|
isActive = isActive,
|
||||||
isAdult = isAdult
|
isAdult = isAdult
|
||||||
),
|
),
|
||||||
messageSource.getMessage("admin.signature_can.updated", langContext.lang)
|
"수정되었습니다."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ class AdminSignatureCanService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) {
|
fun createSignatureCan(can: Int, time: Int, creatorId: Long, image: MultipartFile, isAdult: Boolean) {
|
||||||
if (creatorId < 1) throw SodaException(messageKey = "admin.signature_can.creator_required")
|
if (creatorId < 1) throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||||
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
||||||
if (time < 3 || time > 20) throw SodaException(messageKey = "admin.signature_can.time_range")
|
if (time < 3 || time > 20) throw SodaException("시간은 3초 이상 20초 이하로 설정할 수 있습니다.")
|
||||||
|
|
||||||
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
val creator = memberRepository.findCreatorByIdOrNull(memberId = creatorId)
|
||||||
?: throw SodaException(messageKey = "admin.signature_can.creator_required")
|
?: throw SodaException("올바른 크리에이터를 선택해 주세요.")
|
||||||
|
|
||||||
val signatureCan = SignatureCan(can = can, isAdult = isAdult)
|
val signatureCan = SignatureCan(can = can, isAdult = isAdult)
|
||||||
signatureCan.creator = creator
|
signatureCan.creator = creator
|
||||||
@@ -76,15 +76,15 @@ class AdminSignatureCanService(
|
|||||||
isAdult: Boolean?
|
isAdult: Boolean?
|
||||||
) {
|
) {
|
||||||
val signatureCan = repository.findByIdOrNull(id = id)
|
val signatureCan = repository.findByIdOrNull(id = id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
if (can != null) {
|
if (can != null) {
|
||||||
if (can <= 0) throw SodaException(messageKey = "admin.signature_can.min_can")
|
if (can <= 0) throw SodaException("1캔 이상 설정할 수 있습니다.")
|
||||||
signatureCan.can = can
|
signatureCan.can = can
|
||||||
}
|
}
|
||||||
|
|
||||||
if (time != null) {
|
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
|
signatureCan.time = time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminAdMediaPartnerService(private val repository: AdMediaPartnerRepositor
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
fun updateMediaPartner(request: UpdateAdMediaPartnerRequest) {
|
||||||
val entity = repository.findByIdOrNull(request.id)
|
val entity = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "admin.media_partner.invalid_access")
|
?: throw SodaException("잘못된 접근입니다")
|
||||||
|
|
||||||
if (request.mediaGroup != null) {
|
if (request.mediaGroup != null) {
|
||||||
entity.mediaGroup = request.mediaGroup
|
entity.mediaGroup = request.mediaGroup
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ class AdminMemberController(private val service: AdminMemberService) {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = ApiResponse.ok(service.searchMember(searchWord, 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")
|
@GetMapping("/creator/all/list")
|
||||||
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
fun getCreatorAllList() = ApiResponse.ok(service.getCreatorAllList())
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface AdminMemberQueryRepository {
|
|||||||
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
fun searchMemberTotalCount(searchWord: String, role: MemberRole? = null): Int
|
||||||
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
fun getCreatorAllList(): List<GetAdminCreatorAllListResponse>
|
||||||
fun findByIdAndActive(memberId: Long): Member?
|
fun findByIdAndActive(memberId: Long): Member?
|
||||||
fun searchMemberByNickname(searchWord: String, limit: Long = 20): List<AdminSimpleMemberResponse>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminMemberQueryRepository {
|
||||||
@@ -122,22 +121,4 @@ class AdminMemberQueryRepositoryImpl(private val queryFactory: JPAQueryFactory)
|
|||||||
.orderBy(member.id.desc())
|
.orderBy(member.id.desc())
|
||||||
.fetchFirst()
|
.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
|
package kr.co.vividnext.sodalive.admin.member
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
|
||||||
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberProvider
|
import kr.co.vividnext.sodalive.member.MemberProvider
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -19,8 +17,6 @@ import java.time.format.DateTimeFormatter
|
|||||||
class AdminMemberService(
|
class AdminMemberService(
|
||||||
private val repository: AdminMemberRepository,
|
private val repository: AdminMemberRepository,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val messageSource: SodaMessageSource,
|
|
||||||
private val langContext: LangContext,
|
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
@@ -28,7 +24,7 @@ class AdminMemberService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun updateMember(request: UpdateMemberRequest) {
|
fun updateMember(request: UpdateMemberRequest) {
|
||||||
val member = repository.findByIdOrNull(request.id)
|
val member = repository.findByIdOrNull(request.id)
|
||||||
?: throw SodaException(messageKey = "admin.member.not_found")
|
?: throw SodaException("해당 유저가 없습니다.")
|
||||||
|
|
||||||
if (member.role != request.userType) {
|
if (member.role != request.userType) {
|
||||||
member.role = request.userType
|
member.role = request.userType
|
||||||
@@ -48,7 +44,7 @@ class AdminMemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun searchMember(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
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 totalCount = repository.searchMemberTotalCount(searchWord = searchWord)
|
||||||
val memberList = processMemberListToGetAdminMemberListResponseItemList(
|
val memberList = processMemberListToGetAdminMemberListResponseItemList(
|
||||||
memberList = repository.searchMember(
|
memberList = repository.searchMember(
|
||||||
@@ -75,7 +71,7 @@ class AdminMemberService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun searchCreator(searchWord: String, pageable: Pageable): GetAdminMemberListResponse {
|
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 totalCount = repository.searchMemberTotalCount(searchWord = searchWord, role = MemberRole.CREATOR)
|
||||||
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
|
val creatorList = processMemberListToGetAdminMemberListResponseItemList(
|
||||||
memberList = repository.searchMember(
|
memberList = repository.searchMember(
|
||||||
@@ -96,18 +92,18 @@ class AdminMemberService(
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.map {
|
.map {
|
||||||
val userType = when (it.role) {
|
val userType = when (it.role) {
|
||||||
MemberRole.ADMIN -> messageSource.getMessage("admin.member.role.admin", langContext.lang).orEmpty()
|
MemberRole.ADMIN -> "관리자"
|
||||||
MemberRole.USER -> messageSource.getMessage("admin.member.role.user", langContext.lang).orEmpty()
|
MemberRole.USER -> "일반회원"
|
||||||
MemberRole.CREATOR -> messageSource.getMessage("admin.member.role.creator", langContext.lang).orEmpty()
|
MemberRole.CREATOR -> "크리에이터"
|
||||||
MemberRole.AGENT -> messageSource.getMessage("admin.member.role.agent", langContext.lang).orEmpty()
|
MemberRole.AGENT -> "에이전트"
|
||||||
MemberRole.BOT -> messageSource.getMessage("admin.member.role.bot", langContext.lang).orEmpty()
|
MemberRole.BOT -> "봇"
|
||||||
}
|
}
|
||||||
|
|
||||||
val loginType = when (it.provider) {
|
val loginType = when (it.provider) {
|
||||||
MemberProvider.EMAIL -> messageSource.getMessage("member.provider.email", langContext.lang).orEmpty()
|
MemberProvider.EMAIL -> "이메일"
|
||||||
MemberProvider.KAKAO -> messageSource.getMessage("member.provider.kakao", langContext.lang).orEmpty()
|
MemberProvider.KAKAO -> "카카오"
|
||||||
MemberProvider.GOOGLE -> messageSource.getMessage("member.provider.google", langContext.lang).orEmpty()
|
MemberProvider.GOOGLE -> "구글"
|
||||||
MemberProvider.APPLE -> messageSource.getMessage("member.provider.apple", langContext.lang).orEmpty()
|
MemberProvider.APPLE -> "애플"
|
||||||
}
|
}
|
||||||
|
|
||||||
val signUpDate = it.createdAt!!
|
val signUpDate = it.createdAt!!
|
||||||
@@ -149,16 +145,10 @@ class AdminMemberService(
|
|||||||
return repository.getCreatorAllList()
|
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
|
@Transactional
|
||||||
fun resetPassword(request: ResetPasswordRequest) {
|
fun resetPassword(request: ResetPasswordRequest) {
|
||||||
val member = repository.findByIdAndActive(memberId = request.memberId)
|
val member = repository.findByIdAndActive(memberId = request.memberId)
|
||||||
?: throw SodaException(messageKey = "admin.member.reset_password_invalid")
|
?: throw SodaException("잘못된 회원정보입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
member.password = passwordEncoder.encode(member.email.split("@")[0])
|
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) {
|
private fun tagExistCheck(request: CreateMemberTagRequest) {
|
||||||
repository.findByTag(request.tag)?.let {
|
repository.findByTag(request.tag)?.let { throw SodaException("이미 등록된 태그 입니다.") }
|
||||||
throw SodaException(messageKey = "admin.member.tag.already_registered")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createTag(tag: String, imagePath: String, isAdult: Boolean) {
|
private fun createTag(tag: String, imagePath: String, isAdult: Boolean) {
|
||||||
@@ -53,7 +51,7 @@ class AdminMemberTagService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun deleteTag(id: Long) {
|
fun deleteTag(id: Long) {
|
||||||
val creatorTag = repository.findByIdOrNull(id)
|
val creatorTag = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
creatorTag.tag = "${creatorTag.tag}_deleted"
|
creatorTag.tag = "${creatorTag.tag}_deleted"
|
||||||
creatorTag.isActive = false
|
creatorTag.isActive = false
|
||||||
@@ -62,7 +60,7 @@ class AdminMemberTagService(
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
|
fun modifyTag(id: Long, image: MultipartFile?, requestString: String) {
|
||||||
val creatorTag = repository.findByIdOrNull(id)
|
val creatorTag = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
?: throw SodaException("잘못된 요청입니다.")
|
||||||
|
|
||||||
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
|
val request = objectMapper.readValue(requestString, CreateMemberTagRequest::class.java)
|
||||||
creatorTag.tag = request.tag
|
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.point.PointRewardPolicy
|
||||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
import kr.co.vividnext.sodalive.useraction.PolicyType
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
data class CreatePointRewardPolicyRequest(
|
data class CreatePointRewardPolicyRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
val policyType: PolicyType,
|
|
||||||
val actionType: ActionType,
|
val actionType: ActionType,
|
||||||
val threshold: Int,
|
val threshold: Int,
|
||||||
val availableCount: Int,
|
|
||||||
val pointAmount: Int,
|
val pointAmount: Int,
|
||||||
val startDate: String,
|
val startDate: String,
|
||||||
val endDate: String
|
val endDate: String
|
||||||
@@ -22,10 +19,8 @@ data class CreatePointRewardPolicyRequest(
|
|||||||
|
|
||||||
return PointRewardPolicy(
|
return PointRewardPolicy(
|
||||||
title = title,
|
title = title,
|
||||||
policyType = policyType,
|
|
||||||
actionType = actionType,
|
actionType = actionType,
|
||||||
threshold = threshold,
|
threshold = threshold,
|
||||||
availableCount = availableCount,
|
|
||||||
pointAmount = pointAmount,
|
pointAmount = pointAmount,
|
||||||
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
startDate = LocalDateTime.parse(startDate, dateTimeFormatter)
|
||||||
.atZone(ZoneId.of("Asia/Seoul"))
|
.atZone(ZoneId.of("Asia/Seoul"))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.admin.point
|
|||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.useraction.ActionType
|
import kr.co.vividnext.sodalive.useraction.ActionType
|
||||||
import kr.co.vividnext.sodalive.useraction.PolicyType
|
|
||||||
|
|
||||||
data class GetPointRewardPolicyListResponse(
|
data class GetPointRewardPolicyListResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -12,10 +11,8 @@ data class GetPointRewardPolicyListResponse(
|
|||||||
data class GetPointRewardPolicyListItem @QueryProjection constructor(
|
data class GetPointRewardPolicyListItem @QueryProjection constructor(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val policyType: PolicyType,
|
|
||||||
val actionType: ActionType,
|
val actionType: ActionType,
|
||||||
val threshold: Int,
|
val threshold: Int,
|
||||||
val availableCount: Int,
|
|
||||||
val pointAmount: Int,
|
val pointAmount: Int,
|
||||||
val startDate: String,
|
val startDate: String,
|
||||||
val endDate: String,
|
val endDate: String,
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ class PointPolicyQueryRepositoryImpl(
|
|||||||
QGetPointRewardPolicyListItem(
|
QGetPointRewardPolicyListItem(
|
||||||
pointRewardPolicy.id,
|
pointRewardPolicy.id,
|
||||||
pointRewardPolicy.title,
|
pointRewardPolicy.title,
|
||||||
pointRewardPolicy.policyType,
|
|
||||||
pointRewardPolicy.actionType,
|
pointRewardPolicy.actionType,
|
||||||
pointRewardPolicy.threshold,
|
pointRewardPolicy.threshold,
|
||||||
pointRewardPolicy.availableCount,
|
|
||||||
pointRewardPolicy.pointAmount,
|
pointRewardPolicy.pointAmount,
|
||||||
getFormattedDate(pointRewardPolicy.startDate),
|
getFormattedDate(pointRewardPolicy.startDate),
|
||||||
getFormattedDate(pointRewardPolicy.endDate),
|
getFormattedDate(pointRewardPolicy.endDate),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PointPolicyService(private val repository: PointPolicyRepository) {
|
|||||||
@Transactional
|
@Transactional
|
||||||
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
fun update(id: Long, request: ModifyPointRewardPolicyRequest) {
|
||||||
val pointPolicy = repository.findByIdOrNull(id)
|
val pointPolicy = repository.findByIdOrNull(id)
|
||||||
?: throw SodaException(messageKey = "admin.point.policy.invalid_access")
|
?: throw SodaException("잘못된 접근입니다.")
|
||||||
|
|
||||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
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.CaseBuilder
|
||||||
import com.querydsl.core.types.dsl.DateTimePath
|
import com.querydsl.core.types.dsl.DateTimePath
|
||||||
import com.querydsl.core.types.dsl.Expressions
|
import com.querydsl.core.types.dsl.Expressions
|
||||||
|
import com.querydsl.core.types.dsl.NumberExpression
|
||||||
import com.querydsl.core.types.dsl.StringTemplate
|
import com.querydsl.core.types.dsl.StringTemplate
|
||||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
import kr.co.vividnext.sodalive.marketing.AdTrackingHistoryType
|
||||||
@@ -66,7 +67,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val firstPaymentTotalAmount = CaseBuilder()
|
val firstPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.FIRST_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val repeatPaymentCount = CaseBuilder()
|
val repeatPaymentCount = CaseBuilder()
|
||||||
@@ -78,7 +79,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
val repeatPaymentTotalAmount = CaseBuilder()
|
val repeatPaymentTotalAmount = CaseBuilder()
|
||||||
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.`when`(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
val allPaymentCount = CaseBuilder()
|
val allPaymentCount = CaseBuilder()
|
||||||
@@ -96,7 +97,7 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
.or(adTrackingHistory.type.eq(AdTrackingHistoryType.REPEAT_PAYMENT))
|
||||||
)
|
)
|
||||||
.then(adTrackingHistory.price)
|
.then(adTrackingHistory.price)
|
||||||
.otherwise(0.toBigDecimal())
|
.otherwise(Expressions.constant(0.0))
|
||||||
.sum()
|
.sum()
|
||||||
|
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -110,11 +111,11 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
loginCount,
|
loginCount,
|
||||||
signUpCount,
|
signUpCount,
|
||||||
firstPaymentCount,
|
firstPaymentCount,
|
||||||
firstPaymentTotalAmount,
|
roundedValueDecimalPlaces2(firstPaymentTotalAmount),
|
||||||
repeatPaymentCount,
|
repeatPaymentCount,
|
||||||
repeatPaymentTotalAmount,
|
roundedValueDecimalPlaces2(repeatPaymentTotalAmount),
|
||||||
allPaymentCount,
|
allPaymentCount,
|
||||||
allPaymentTotalAmount
|
roundedValueDecimalPlaces2(allPaymentTotalAmount)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.from(adTrackingHistory)
|
.from(adTrackingHistory)
|
||||||
@@ -147,4 +148,13 @@ class AdminAdStatisticsRepository(private val queryFactory: JPAQueryFactory) {
|
|||||||
"%Y-%m-%d"
|
"%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
|
package kr.co.vividnext.sodalive.admin.statistics.ad
|
||||||
|
|
||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
data class GetAdminAdStatisticsResponse(
|
data class GetAdminAdStatisticsResponse(
|
||||||
val totalCount: Int,
|
val totalCount: Int,
|
||||||
@@ -17,9 +16,9 @@ data class GetAdminAdStatisticsItem @QueryProjection constructor(
|
|||||||
val loginCount: Int,
|
val loginCount: Int,
|
||||||
val signUpCount: Int,
|
val signUpCount: Int,
|
||||||
val firstPaymentCount: Int,
|
val firstPaymentCount: Int,
|
||||||
val firstPaymentTotalAmount: BigDecimal,
|
val firstPaymentTotalAmount: Double,
|
||||||
val repeatPaymentCount: Int,
|
val repeatPaymentCount: Int,
|
||||||
val repeatPaymentTotalAmount: BigDecimal,
|
val repeatPaymentTotalAmount: Double,
|
||||||
val allPaymentCount: Int,
|
val allPaymentCount: Int,
|
||||||
val allPaymentTotalAmount: BigDecimal
|
val allPaymentTotalAmount: Double
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class AdminMemberStatisticsService(private val repository: AdminMemberStatistics
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (dateRange == null) {
|
if (dateRange == null) {
|
||||||
throw SodaException(messageKey = "admin.member.statistics.invalid_access")
|
throw SodaException("잘못된 접근입니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var startDateTime = startDate.atStartOfDay()
|
var startDateTime = startDate.atStartOfDay()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AlarmController(private val service: AlarmService) {
|
|||||||
fun getSlotQuantityAndPrice(
|
fun getSlotQuantityAndPrice(
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getSlotQuantityAndPrice(memberId = member.id!!)
|
service.getSlotQuantityAndPrice(memberId = member.id!!)
|
||||||
@@ -29,7 +29,7 @@ class AlarmController(private val service: AlarmService) {
|
|||||||
@PathVariable("container") container: String,
|
@PathVariable("container") container: String,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.buyExtraSlot(
|
service.buyExtraSlot(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class AlarmService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
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,
|
@PathVariable id: Long,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(service.getAuditionDetail(auditionId = id))
|
ApiResponse.ok(service.getAuditionDetail(auditionId = id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ interface AuditionQueryRepository {
|
|||||||
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
fun getCompletedAuditionCount(isAdult: Boolean): Int
|
||||||
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): List<GetAuditionListItem>
|
||||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||||
fun getInProgressAuditionList(isAdult: Boolean): List<GetAuditionListItem>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuditionQueryRepositoryImpl(
|
class AuditionQueryRepositoryImpl(
|
||||||
@@ -95,27 +94,4 @@ class AuditionQueryRepositoryImpl(
|
|||||||
.where(audition.id.eq(auditionId))
|
.where(audition.id.eq(auditionId))
|
||||||
.fetchFirst()
|
.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
|
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?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getAuditionApplicantList(
|
service.getAuditionApplicantList(
|
||||||
@@ -42,7 +42,7 @@ class AuditionApplicantController(private val service: AuditionApplicantService)
|
|||||||
@RequestPart("request") requestString: String,
|
@RequestPart("request") requestString: String,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.applyAuditionRole(
|
service.applyAuditionRole(
|
||||||
|
|||||||
@@ -47,11 +47,11 @@ class AuditionApplicantService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) {
|
fun applyAuditionRole(contentFile: MultipartFile?, requestString: String, member: Member) {
|
||||||
if (contentFile == null) throw SodaException(messageKey = "audition.applicant.content_file_required")
|
if (contentFile == null) throw SodaException("녹음 파일을 확인해 주세요.")
|
||||||
val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java)
|
val request = objectMapper.readValue(requestString, ApplyAuditionRoleRequest::class.java)
|
||||||
|
|
||||||
val auditionRole = roleRepository.findByIdOrNull(id = request.roleId)
|
val auditionRole = roleRepository.findByIdOrNull(id = request.roleId)
|
||||||
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId(
|
val existingApplicant = repository.findActiveApplicantByMemberIdAndRoleId(
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class AuditionRoleController(private val service: AuditionRoleService) {
|
|||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
|
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
|
||||||
|
|
||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
service.getAuditionRoleDetail(
|
service.getAuditionRoleDetail(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AuditionRoleService(
|
|||||||
) {
|
) {
|
||||||
fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse {
|
fun getAuditionRoleDetail(auditionRoleId: Long, memberId: Long): GetAuditionRoleDetailResponse {
|
||||||
val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
val roleDetailData = repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
||||||
?: throw SodaException(messageKey = "audition.error.invalid_request_retry")
|
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||||
|
|
||||||
val isAlreadyApplicant = applicantRepository.isAlreadyApplicant(
|
val isAlreadyApplicant = applicantRepository.isAlreadyApplicant(
|
||||||
auditionRoleId = auditionRoleId,
|
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