Compare commits
176 Commits
test
...
7aa5884797
Author | SHA1 | Date | |
---|---|---|---|
7aa5884797 | |||
5b237a1547 | |||
2e37990d87 | |||
dd07d724a8 | |||
03ce8618e7 | |||
db1a7a7fd6 | |||
36a82d7f53 | |||
3a34401113 | |||
9927268330 | |||
c45c97e29d | |||
c64a315226 | |||
a4cafca6ab | |||
46284a0660 | |||
05df86e15a | |||
8b433027e2 | |||
5bd4ff7610 | |||
d693c397ea | |||
1d8d1ec9a5 | |||
5e491f11ee | |||
7cedea06ac | |||
2e5f750e50 | |||
20289cad10 | |||
e0d64c31c7 | |||
8c1b95dc97 | |||
fb5641343e | |||
87765941eb | |||
1809862c16 | |||
300f784f7d | |||
67a045eae6 | |||
2a79903a28 | |||
d3222ce083 | |||
406a421742 | |||
10bf728faf | |||
607617747c | |||
f0a69eb1a2 | |||
6b307a6e17 | |||
08d08a934a | |||
c500c12668 | |||
62060adeba | |||
b2fc75edb8 | |||
a999dd2085 | |||
49f95ab100 | |||
1a84d5b30c | |||
3b65050632 | |||
d0df31674c | |||
1fe88402e2 | |||
67097696e6 | |||
8e7e77067a | |||
9899390b61 | |||
80c476a908 | |||
59da1d6e49 | |||
5aef7dac33 | |||
faf7aa06b6 | |||
38ef6e5583 | |||
c0b15b5d94 | |||
2cfc067ea1 | |||
a91db4f956 | |||
8a09780a02 | |||
45e8ec6505 | |||
4554b85914 | |||
8aa79c4a9c | |||
c8d3210b57 | |||
2282a49563 | |||
b82fdfb2c8 | |||
2d17eac199 | |||
e482bc3aad | |||
ec022b74d1 | |||
dc42c09ce3 | |||
046a34d2a4 | |||
9ff6ec1888 | |||
d2950106ec | |||
962f800d2e | |||
962107e507 | |||
039bd11963 | |||
5c250ea4ae | |||
e3405bcec6 | |||
0fd1c2235f | |||
b20c29b022 | |||
12d5dcd298 | |||
2c305dc6c6 | |||
62f76f7433 | |||
858ce524f9 | |||
3795fb4a40 | |||
0c01aeec50 | |||
892206744d | |||
9e2c1474db | |||
16328f73d9 | |||
e0d4f53cf4 | |||
e09a59c5b4 | |||
049e654535 | |||
c927dc4ecd | |||
fe4ecd0ad8 | |||
78d476fe80 | |||
a11c8465d5 | |||
366304a9b7 | |||
4356663688 | |||
26b55e6fcf | |||
0d743f7204 | |||
6cbe113b3e | |||
6409b69d6c | |||
c5164c76fc | |||
baade8e138 | |||
b848d6b4e0 | |||
d8139d2ab0 | |||
e96d8f7469 | |||
2acffd8afc | |||
3c8e72073c | |||
724d7a9d9b | |||
2da3b0db78 | |||
685ad7afaf | |||
264cf75964 | |||
c773dbc7b5 | |||
37cbc64f52 | |||
cb1dde17bb | |||
c29988acf4 | |||
eadbf56dae | |||
4b3b455135 | |||
e6ac177396 | |||
3d0e29003f | |||
78b9b00f77 | |||
0ee7faa551 | |||
e5fdced681 | |||
afb99fef64 | |||
7dfaa36024 | |||
0496f665aa | |||
0d19e1be74 | |||
4aff0111aa | |||
63b3ba2bb2 | |||
7444b41f60 | |||
8e90dbc8b6 | |||
9f70722521 | |||
52fae596fa | |||
ccb67957bc | |||
fb82538d0d | |||
72ee39612e | |||
51fd5408dc | |||
3fae40fbef | |||
0745890af0 | |||
4abe1730a7 | |||
626f0e6989 | |||
9f42d9d173 | |||
f90a93c4bc | |||
8000ad6c6a | |||
1f1f1bea1a | |||
d95460c7cd | |||
a3d93d4b08 | |||
07a92af982 | |||
f4618877d4 | |||
2b914fd222 | |||
109e42a5a3 | |||
fa515ad39c | |||
f09673a795 | |||
f71536c614 | |||
7bdddc7ae8 | |||
aa8926a624 | |||
be71e59be2 | |||
4d7753378f | |||
60257c4ef4 | |||
1e0b79bf62 | |||
6883434d0d | |||
eda2193e64 | |||
99bf829c88 | |||
5feafe1b48 | |||
c9292b7d04 | |||
ae7e1a91c1 | |||
3e1887e0d1 | |||
474646db47 | |||
56f7b6c449 | |||
76b2b5f7e3 | |||
e918d809eb | |||
7af059e543 | |||
897726e1ec | |||
8b98a2dd07 | |||
cca75420f0 | |||
86c627ed1d | |||
d55514e3a7 |
@@ -7,5 +7,5 @@ indent_size = 4
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 130
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -323,7 +323,4 @@ gradle-app.setting
|
||||
### Gradle Patch ###
|
||||
**/build/
|
||||
|
||||
.kiro/
|
||||
.junie
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle
|
||||
|
@@ -26,8 +26,6 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
||||
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
@@ -65,13 +63,7 @@ dependencies {
|
||||
// android publisher
|
||||
implementation("com.google.apis:google-api-services-androidpublisher:v3-rev20240319-2.0.0")
|
||||
|
||||
implementation("com.google.api-client:google-api-client:1.32.1")
|
||||
|
||||
implementation("org.apache.poi:poi-ooxml:5.2.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
|
||||
|
||||
// file mimetype check
|
||||
implementation("org.apache.tika:tika-core:3.2.0")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
runtimeOnly("com.h2database:h2")
|
||||
|
@@ -1,43 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/audition")
|
||||
class AdminAuditionController(private val service: AdminAuditionService) {
|
||||
@PostMapping
|
||||
fun createAudition(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = ApiResponse.ok(service.createAudition(image, requestString), "등록되었습니다.")
|
||||
|
||||
@PutMapping
|
||||
fun updateAudition(
|
||||
@RequestPart("image", required = false) image: MultipartFile? = null,
|
||||
@RequestPart("request") requestString: String
|
||||
) = ApiResponse.ok(service.updateAudition(image, requestString), "수정되었습니다.")
|
||||
|
||||
@GetMapping
|
||||
fun getAuditionList(pageable: Pageable) = ApiResponse.ok(
|
||||
service.getAuditionList(
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getAuditionDetail(@PathVariable id: Long) = ApiResponse.ok(
|
||||
service.getAuditionDetail(auditionId = id)
|
||||
)
|
||||
}
|
@@ -1,69 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.audition.Audition
|
||||
import kr.co.vividnext.sodalive.audition.QAudition.audition
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface AdminAuditionRepository : JpaRepository<Audition, Long>, AdminAuditionQueryRepository
|
||||
|
||||
interface AdminAuditionQueryRepository {
|
||||
fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem>
|
||||
fun getAuditionListCount(): Int
|
||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData
|
||||
}
|
||||
|
||||
class AdminAuditionQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
) : AdminAuditionQueryRepository {
|
||||
override fun getAuditionList(offset: Long, limit: Long): List<GetAuditionListItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionListItem(
|
||||
audition.id,
|
||||
audition.title,
|
||||
audition.imagePath.prepend("/").prepend(coverImageHost),
|
||||
audition.isAdult,
|
||||
audition.information,
|
||||
audition.status,
|
||||
audition.originalWorkUrl.coalesce("")
|
||||
)
|
||||
)
|
||||
.from(audition)
|
||||
.where(audition.isActive.isTrue)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(audition.isActive.desc(), audition.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getAuditionListCount(): Int {
|
||||
return queryFactory
|
||||
.select(audition.id)
|
||||
.from(audition)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
override fun getAuditionDetail(auditionId: Long): GetAuditionDetailRawData {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionDetailRawData(
|
||||
audition.id,
|
||||
audition.title,
|
||||
audition.imagePath.prepend("/").prepend(coverImageHost),
|
||||
audition.information,
|
||||
audition.originalWorkUrl.coalesce("")
|
||||
)
|
||||
)
|
||||
.from(audition)
|
||||
.where(audition.id.eq(auditionId))
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
@@ -1,122 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.audition.role.AdminAuditionRoleRepository
|
||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@Service
|
||||
class AdminAuditionService(
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminAuditionRepository,
|
||||
private val roleRepository: AdminAuditionRoleRepository,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@Transactional
|
||||
fun createAudition(image: MultipartFile, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, CreateAuditionRequest::class.java)
|
||||
val audition = repository.save(request.toAudition())
|
||||
|
||||
val fileName = generateFileName("audition")
|
||||
val imagePath = s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = "audition/production/${audition.id}/$fileName"
|
||||
)
|
||||
audition.imagePath = imagePath
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateAudition(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateAuditionRequest::class.java)
|
||||
val audition = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
if (request.title != null) {
|
||||
audition.title = request.title
|
||||
}
|
||||
|
||||
if (request.information != null) {
|
||||
audition.information = request.information
|
||||
}
|
||||
|
||||
if (request.isAdult != null) {
|
||||
audition.isAdult = request.isAdult
|
||||
}
|
||||
|
||||
if (request.status != null) {
|
||||
if (
|
||||
(audition.status == AuditionStatus.COMPLETED || audition.status == AuditionStatus.IN_PROGRESS) &&
|
||||
request.status == AuditionStatus.NOT_STARTED
|
||||
) {
|
||||
throw SodaException("모집전 상태로 변경할 수 없습니다.")
|
||||
}
|
||||
|
||||
audition.status = request.status
|
||||
}
|
||||
|
||||
if (request.originalWorkUrl != null) {
|
||||
audition.originalWorkUrl = request.originalWorkUrl
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
val fileName = generateFileName("audition")
|
||||
val imagePath = s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = "audition/production/${audition.id}/$fileName"
|
||||
)
|
||||
audition.imagePath = imagePath
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
audition.isActive = request.isActive
|
||||
}
|
||||
|
||||
if (request.status != null && request.status == AuditionStatus.IN_PROGRESS && audition.isActive) {
|
||||
applicationEventPublisher.publishEvent(
|
||||
FcmEvent(
|
||||
type = FcmEventType.IN_PROGRESS_AUDITION,
|
||||
title = "새로운 오디션 등록!",
|
||||
message = "'${audition.title}'이 등록되었습니다. 지금 바로 오리지널 오디오 드라마 오디션에 지원해보세요!",
|
||||
isAuth = audition.isAdult,
|
||||
auditionId = audition.id ?: -1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuditionList(offset: Long, limit: Long): GetAuditionListResponse {
|
||||
val totalCount = repository.getAuditionListCount()
|
||||
val items = repository.getAuditionList(offset = offset, limit = limit)
|
||||
return GetAuditionListResponse(totalCount, items)
|
||||
}
|
||||
|
||||
fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse {
|
||||
val auditionDetail = repository.getAuditionDetail(auditionId = auditionId)
|
||||
val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId)
|
||||
|
||||
return GetAuditionDetailResponse(
|
||||
id = auditionDetail.id,
|
||||
title = auditionDetail.title,
|
||||
imageUrl = auditionDetail.imageUrl,
|
||||
information = auditionDetail.information,
|
||||
originalWorkUrl = auditionDetail.originalWorkUrl,
|
||||
roleList = roleList
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.Audition
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
|
||||
data class CreateAuditionRequest(
|
||||
val title: String,
|
||||
val information: String,
|
||||
val isAdult: Boolean = false,
|
||||
val originalWorkUrl: String? = null
|
||||
) {
|
||||
init {
|
||||
if (title.isBlank()) {
|
||||
throw SodaException("오디션 제목을 입력하세요")
|
||||
}
|
||||
|
||||
if (information.isBlank() || information.length < 10) {
|
||||
throw SodaException("오디션 정보는 최소 10글자 입니다")
|
||||
}
|
||||
}
|
||||
|
||||
fun toAudition(): Audition {
|
||||
return Audition(
|
||||
title = title,
|
||||
information = information,
|
||||
isAdult = isAdult,
|
||||
originalWorkUrl = originalWorkUrl
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||
|
||||
data class GetAuditionDetailRawData @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val information: String,
|
||||
val originalWorkUrl: String
|
||||
)
|
||||
|
||||
data class GetAuditionDetailResponse(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val information: String,
|
||||
val originalWorkUrl: String,
|
||||
val roleList: List<GetAuditionRoleListData>
|
||||
)
|
||||
|
||||
data class GetAuditionRoleListData @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val imageUrl: String,
|
||||
val information: String,
|
||||
val auditionScriptUrl: String,
|
||||
val status: AuditionStatus
|
||||
)
|
@@ -1,19 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||
|
||||
data class GetAuditionListResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetAuditionListItem>
|
||||
)
|
||||
|
||||
data class GetAuditionListItem @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val imageUrl: String,
|
||||
val isAdult: Boolean,
|
||||
val information: String,
|
||||
val status: AuditionStatus,
|
||||
val originalWorkUrl: String
|
||||
)
|
@@ -1,13 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||
|
||||
data class UpdateAuditionRequest(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val information: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val status: AuditionStatus? = null,
|
||||
val originalWorkUrl: String? = null,
|
||||
val isActive: Boolean? = null
|
||||
)
|
@@ -1,19 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/audition/applicant")
|
||||
class AdminAuditionApplicantController(private val service: AdminAuditionApplicantService) {
|
||||
@DeleteMapping("/{id}")
|
||||
fun deleteAuditionApplicant(@PathVariable id: Long) = ApiResponse.ok(
|
||||
service.deleteAuditionApplicant(id),
|
||||
"오디션 지원이 취소 되었습니다."
|
||||
)
|
||||
}
|
@@ -1,6 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionApplicant
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminAuditionApplicantRepository : JpaRepository<AuditionApplicant, Long>
|
@@ -1,17 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.applicant
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminAuditionApplicantService(private val repository: AdminAuditionApplicantRepository) {
|
||||
@Transactional
|
||||
fun deleteAuditionApplicant(id: Long) {
|
||||
val applicant = repository.findByIdOrNull(id)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
applicant.isActive = false
|
||||
}
|
||||
}
|
@@ -1,47 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestPart
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/audition/role")
|
||||
class AdminAuditionRoleController(private val service: AdminAuditionRoleService) {
|
||||
@PostMapping
|
||||
fun createAuditionRole(
|
||||
@RequestPart("image") image: MultipartFile,
|
||||
@RequestPart("request") requestString: String
|
||||
) = ApiResponse.ok(service.createAuditionRole(image, requestString), "등록되었습니다.")
|
||||
|
||||
@PutMapping
|
||||
fun updateAuditionRole(
|
||||
@RequestPart("image", required = false) image: MultipartFile? = null,
|
||||
@RequestPart("request") requestString: String
|
||||
) = ApiResponse.ok(service.updateAuditionRole(image, requestString), "수정되었습니다.")
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getAuditionRoleDetail(@PathVariable id: Long) = ApiResponse.ok(
|
||||
service.getAuditionRoleDetail(auditionRoleId = id)
|
||||
)
|
||||
|
||||
@GetMapping("/{id}/applicant")
|
||||
fun getAuditionApplicantList(
|
||||
@PathVariable id: Long,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getAuditionApplicantList(
|
||||
auditionRoleId = id,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
}
|
@@ -1,106 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.audition.GetAuditionRoleListData
|
||||
import kr.co.vividnext.sodalive.admin.audition.QGetAuditionRoleListData
|
||||
import kr.co.vividnext.sodalive.audition.AuditionRole
|
||||
import kr.co.vividnext.sodalive.audition.QAudition.audition
|
||||
import kr.co.vividnext.sodalive.audition.QAuditionApplicant.auditionApplicant
|
||||
import kr.co.vividnext.sodalive.audition.QAuditionRole.auditionRole
|
||||
import kr.co.vividnext.sodalive.audition.QAuditionVote.auditionVote
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminAuditionRoleRepository : JpaRepository<AuditionRole, Long>, AdminAuditionRoleQueryRepository
|
||||
|
||||
interface AdminAuditionRoleQueryRepository {
|
||||
fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData>
|
||||
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse
|
||||
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): List<GetAuditionRoleApplicantItem>
|
||||
fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int
|
||||
}
|
||||
|
||||
class AdminAuditionRoleQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudfrontHost: String
|
||||
) : AdminAuditionRoleQueryRepository {
|
||||
override fun getAuditionRoleListByAuditionId(auditionId: Long): List<GetAuditionRoleListData> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionRoleListData(
|
||||
auditionRole.id,
|
||||
auditionRole.name,
|
||||
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
|
||||
auditionRole.information,
|
||||
auditionRole.auditionScriptUrl,
|
||||
auditionRole.status
|
||||
)
|
||||
)
|
||||
.from(auditionRole)
|
||||
.innerJoin(auditionRole.audition, audition)
|
||||
.where(
|
||||
auditionRole.audition.id.eq(auditionId),
|
||||
auditionRole.isActive.isTrue
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionRoleDetailResponse(
|
||||
auditionRole.name,
|
||||
auditionRole.imagePath.prepend("/").prepend(cloudfrontHost),
|
||||
auditionRole.information,
|
||||
auditionRole.auditionScriptUrl
|
||||
)
|
||||
)
|
||||
.from(auditionRole)
|
||||
.where(auditionRole.id.eq(auditionRoleId))
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun getAuditionApplicantList(
|
||||
auditionRoleId: Long,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetAuditionRoleApplicantItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAuditionRoleApplicantItem(
|
||||
auditionApplicant.id,
|
||||
member.nickname,
|
||||
member.profileImage.prepend("/").prepend(cloudfrontHost),
|
||||
auditionApplicant.phoneNumber,
|
||||
auditionApplicant.voicePath.prepend("/").prepend(cloudfrontHost),
|
||||
auditionVote.id.count()
|
||||
)
|
||||
)
|
||||
.from(auditionApplicant)
|
||||
.innerJoin(auditionApplicant.member, member)
|
||||
.innerJoin(auditionApplicant.role, auditionRole)
|
||||
.leftJoin(auditionVote).on(auditionApplicant.id.eq(auditionVote.applicant.id))
|
||||
.where(
|
||||
auditionRole.id.eq(auditionRoleId),
|
||||
auditionApplicant.isActive.isTrue
|
||||
)
|
||||
.groupBy(auditionApplicant.id)
|
||||
.orderBy(auditionVote.id.count().desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getAuditionApplicantTotalCount(auditionRoleId: Long): Int {
|
||||
return queryFactory
|
||||
.select(auditionApplicant.id)
|
||||
.from(auditionApplicant)
|
||||
.innerJoin(auditionApplicant.role, auditionRole)
|
||||
.where(auditionRole.id.eq(auditionRoleId))
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
}
|
@@ -1,95 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.audition.AdminAuditionRepository
|
||||
import kr.co.vividnext.sodalive.audition.AuditionRole
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
|
||||
@Service
|
||||
class AdminAuditionRoleService(
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val repository: AdminAuditionRoleRepository,
|
||||
private val auditionRepository: AdminAuditionRepository,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String
|
||||
) {
|
||||
@Transactional
|
||||
fun createAuditionRole(image: MultipartFile, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, CreateAuditionRoleRequest::class.java)
|
||||
val auditionRole = AuditionRole(
|
||||
name = request.name,
|
||||
information = request.information,
|
||||
auditionScriptUrl = request.auditionScriptUrl
|
||||
)
|
||||
val audition = auditionRepository.findByIdOrNull(id = request.auditionId)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
auditionRole.audition = audition
|
||||
repository.save(auditionRole)
|
||||
|
||||
val fileName = generateFileName("audition_role")
|
||||
val imagePath = s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = "audition/role/${auditionRole.id}/$fileName"
|
||||
)
|
||||
auditionRole.imagePath = imagePath
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateAuditionRole(image: MultipartFile?, requestString: String) {
|
||||
val request = objectMapper.readValue(requestString, UpdateAuditionRoleRequest::class.java)
|
||||
val auditionRole = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.")
|
||||
|
||||
if (!request.name.isNullOrBlank()) {
|
||||
if (request.name.length < 2) throw SodaException("배역 이름은 최소 2글자 입니다")
|
||||
auditionRole.name = request.name
|
||||
}
|
||||
|
||||
if (!request.information.isNullOrBlank()) {
|
||||
if (request.information.length < 10) throw SodaException("오디션 배역 정보는 최소 10글자 입니다")
|
||||
auditionRole.information = request.information
|
||||
}
|
||||
|
||||
if (!request.auditionScriptUrl.isNullOrBlank()) {
|
||||
auditionRole.auditionScriptUrl = request.auditionScriptUrl
|
||||
}
|
||||
|
||||
if (request.status != null) {
|
||||
auditionRole.status = request.status
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
auditionRole.isActive = request.isActive
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
val fileName = generateFileName("audition_role")
|
||||
val imagePath = s3Uploader.upload(
|
||||
inputStream = image.inputStream,
|
||||
bucket = bucket,
|
||||
filePath = "audition/role/${auditionRole.id}/$fileName"
|
||||
)
|
||||
auditionRole.imagePath = imagePath
|
||||
}
|
||||
}
|
||||
|
||||
fun getAuditionRoleDetail(auditionRoleId: Long): GetAuditionRoleDetailResponse {
|
||||
return repository.getAuditionRoleDetail(auditionRoleId = auditionRoleId)
|
||||
}
|
||||
|
||||
fun getAuditionApplicantList(auditionRoleId: Long, offset: Long, limit: Long): GetAuditionRoleApplicantResponse {
|
||||
val totalCount = repository.getAuditionApplicantTotalCount(auditionRoleId = auditionRoleId)
|
||||
val items = repository.getAuditionApplicantList(auditionRoleId = auditionRoleId, offset = offset, limit = limit)
|
||||
return GetAuditionRoleApplicantResponse(totalCount, items)
|
||||
}
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
|
||||
data class CreateAuditionRoleRequest(
|
||||
val auditionId: Long,
|
||||
val name: String,
|
||||
val information: String,
|
||||
val auditionScriptUrl: String
|
||||
) {
|
||||
init {
|
||||
if (auditionId < 0) {
|
||||
throw SodaException("캐릭터가 등록될 오디션을 선택하세요")
|
||||
}
|
||||
|
||||
if (name.isBlank() || name.length < 2) {
|
||||
throw SodaException("캐릭터명을 입력하세요")
|
||||
}
|
||||
|
||||
if (auditionScriptUrl.isBlank() || auditionScriptUrl.length < 10) {
|
||||
throw SodaException("오디션 대본 URL을 입력하세요")
|
||||
}
|
||||
|
||||
if (information.isBlank() || information.length < 10) {
|
||||
throw SodaException("오디션 캐릭터 정보는 최소 10글자 입니다")
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAuditionRoleApplicantResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetAuditionRoleApplicantItem>
|
||||
)
|
||||
|
||||
data class GetAuditionRoleApplicantItem @QueryProjection constructor(
|
||||
val applicantId: Long,
|
||||
val nickname: String,
|
||||
val profileImageUrl: String,
|
||||
val phoneNumber: String,
|
||||
val voiceUrl: String,
|
||||
val voteCount: Long
|
||||
)
|
@@ -1,10 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAuditionRoleDetailResponse @QueryProjection constructor(
|
||||
val name: String,
|
||||
val imageUrl: String,
|
||||
val information: String,
|
||||
val auditionScriptUrl: String
|
||||
)
|
@@ -1,19 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.audition.role
|
||||
|
||||
import kr.co.vividnext.sodalive.audition.AuditionStatus
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
|
||||
data class UpdateAuditionRoleRequest(
|
||||
val id: Long,
|
||||
val name: String? = null,
|
||||
val information: String? = null,
|
||||
val auditionScriptUrl: String? = null,
|
||||
val status: AuditionStatus? = null,
|
||||
val isActive: Boolean? = null
|
||||
) {
|
||||
init {
|
||||
if (id < 0) {
|
||||
throw SodaException("잘못된 요청입니다.")
|
||||
}
|
||||
}
|
||||
}
|
@@ -34,60 +34,4 @@ class AdminCalculateController(private val service: AdminCalculateService) {
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String
|
||||
) = ApiResponse.ok(service.getCalculateContentDonationList(startDateStr, endDateStr))
|
||||
|
||||
@GetMapping("/community-post")
|
||||
fun getCalculateCommunityPost(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCalculateCommunityPost(
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
pageable.offset,
|
||||
pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/live-by-creator")
|
||||
fun getCalculateLiveByCreator(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCalculateLiveByCreator(
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
pageable.offset,
|
||||
pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/content-by-creator")
|
||||
fun getCalculateContentByCreator(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCalculateContentByCreator(
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
pageable.offset,
|
||||
pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
|
||||
@GetMapping("/community-by-creator")
|
||||
fun getCalculateCommunityByCreator(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam endDateStr: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCalculateCommunityByCreator(
|
||||
startDateStr,
|
||||
endDateStr,
|
||||
pageable.offset,
|
||||
pageable.pageSize.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@@ -1,16 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.core.types.dsl.DateTimePath
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.core.types.dsl.StringTemplate
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
|
||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -31,34 +28,24 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
liveRoom.price,
|
||||
useCan.canUsage,
|
||||
useCan.id.count(),
|
||||
useCan.can.add(useCan.rewardCan).sum(),
|
||||
creatorSettlementRatio.liveSettlementRatio
|
||||
useCan.can.add(useCan.rewardCan).sum()
|
||||
)
|
||||
)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
.and(liveRoom.beginDateTime.goe(startDate))
|
||||
.and(liveRoom.beginDateTime.loe(endDate))
|
||||
)
|
||||
.groupBy(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
|
||||
.groupBy(liveRoom.id, useCan.canUsage)
|
||||
.orderBy(member.nickname.desc(), liveRoom.id.desc(), useCan.canUsage.desc(), formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCalculateContentList(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateContentQueryData> {
|
||||
val orderFormattedDate = getFormattedDate(order.createdAt)
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateContentQueryData(
|
||||
@@ -69,32 +56,18 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
order.type,
|
||||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
order.can.sum()
|
||||
)
|
||||
)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
.and(order.isActive.isTrue)
|
||||
)
|
||||
.groupBy(
|
||||
audioContent.id,
|
||||
order.type,
|
||||
orderFormattedDate,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.groupBy(audioContent.id, order.type, orderFormattedDate, order.can)
|
||||
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
|
||||
.fetch()
|
||||
}
|
||||
@@ -119,17 +92,12 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(member.id, audioContent.id, order.can)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getCumulativeSalesByContent(offset: Long, limit: Long): List<GetCumulativeSalesByContentQueryData> {
|
||||
val pointGroup = CaseBuilder()
|
||||
.`when`(order.point.loe(0)).then(0)
|
||||
.otherwise(1)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCumulativeSalesByContentQueryData(
|
||||
@@ -139,28 +107,13 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
order.type,
|
||||
order.can,
|
||||
order.id.count(),
|
||||
order.can.sum(),
|
||||
order.point.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
order.can.sum()
|
||||
)
|
||||
)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(order.isActive.isTrue)
|
||||
.groupBy(
|
||||
member.id,
|
||||
audioContent.id,
|
||||
order.type,
|
||||
order.can,
|
||||
pointGroup,
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
.groupBy(member.id, audioContent.id, order.type, order.can)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(member.id.desc(), audioContent.id.desc())
|
||||
@@ -194,235 +147,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(donationFormattedDate, audioContent.id)
|
||||
.orderBy(member.id.asc(), donationFormattedDate.desc(), audioContent.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCalculateCommunityPostTotalCount(startDate: LocalDateTime?, endDate: LocalDateTime?): Int {
|
||||
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||
return queryFactory
|
||||
.select(creatorCommunity.id)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(formattedDate, creatorCommunity.id)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getCalculateCommunityPostList(
|
||||
startDate: LocalDateTime?,
|
||||
endDate: LocalDateTime?,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetCalculateCommunityPostQueryData> {
|
||||
val formattedDate = getFormattedDate(useCan.createdAt)
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateCommunityPostQueryData(
|
||||
member.nickname,
|
||||
Expressions.stringTemplate("substring({0}, 1, 10)", creatorCommunity.content),
|
||||
formattedDate,
|
||||
creatorCommunity.price,
|
||||
useCan.id.count(),
|
||||
useCan.can.add(useCan.rewardCan).sum(),
|
||||
creatorSettlementRatio.communitySettlementRatio
|
||||
)
|
||||
)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(formattedDate, creatorCommunity.id, creatorSettlementRatio.communitySettlementRatio)
|
||||
.orderBy(member.id.asc(), formattedDate.desc(), creatorCommunity.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCalculateLiveByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||
return queryFactory
|
||||
.select(member.id)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(member.id)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getCalculateLiveByCreator(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetCalculateByCreatorQueryData> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateByCreatorQueryData(
|
||||
member.email,
|
||||
member.nickname,
|
||||
useCan.can.add(useCan.rewardCan).sum(),
|
||||
creatorSettlementRatio.liveSettlementRatio
|
||||
)
|
||||
)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.room, liveRoom)
|
||||
.innerJoin(liveRoom.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(member.id, creatorSettlementRatio.liveSettlementRatio)
|
||||
.orderBy(member.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCalculateContentByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||
return queryFactory
|
||||
.select(member.id)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
.and(order.isActive.isTrue)
|
||||
)
|
||||
.groupBy(member.id)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getCalculateContentByCreator(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetCalculateByCreatorQueryData> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateByCreatorQueryData(
|
||||
member.email,
|
||||
member.nickname,
|
||||
order.can.sum(),
|
||||
creatorSettlementRatio.contentSettlementRatio
|
||||
)
|
||||
)
|
||||
.from(order)
|
||||
.innerJoin(order.audioContent, audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
order.createdAt.goe(startDate)
|
||||
.and(order.createdAt.loe(endDate))
|
||||
.and(order.isActive.isTrue)
|
||||
)
|
||||
.groupBy(member.id)
|
||||
.orderBy(member.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getCalculateCommunityByCreatorTotalCount(startDate: LocalDateTime, endDate: LocalDateTime): Int {
|
||||
return queryFactory
|
||||
.select(member.id)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(member.id)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
fun getCalculateCommunityByCreator(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetCalculateByCreatorQueryData> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCalculateByCreatorQueryData(
|
||||
member.email,
|
||||
member.nickname,
|
||||
useCan.can.add(useCan.rewardCan).sum(),
|
||||
creatorSettlementRatio.communitySettlementRatio
|
||||
)
|
||||
)
|
||||
.from(useCan)
|
||||
.innerJoin(useCan.communityPost, creatorCommunity)
|
||||
.innerJoin(creatorCommunity.member, member)
|
||||
.leftJoin(creatorSettlementRatio)
|
||||
.on(
|
||||
member.id.eq(creatorSettlementRatio.member.id)
|
||||
.and(creatorSettlementRatio.deletedAt.isNull)
|
||||
)
|
||||
.where(
|
||||
useCan.isRefund.isFalse
|
||||
.and(useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST))
|
||||
.and(useCan.createdAt.goe(startDate))
|
||||
.and(useCan.createdAt.loe(endDate))
|
||||
)
|
||||
.groupBy(member.id, creatorSettlementRatio.communitySettlementRatio)
|
||||
.orderBy(member.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(member.id.asc(), donationFormattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
@@ -1,25 +1,90 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
|
||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import org.springframework.cache.annotation.Cacheable
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Service
|
||||
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(
|
||||
cacheNames = ["cache_ttl_3_hours"],
|
||||
cacheNames = ["default"],
|
||||
key = "'calculateLive:' + " + "#startDateStr + ':' + #endDateStr"
|
||||
)
|
||||
fun getCalculateLive(startDateStr: String, endDateStr: String): List<GetCalculateLiveResponse> {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository
|
||||
.getCalculateLive(startDate, endDate)
|
||||
.map { it.toGetCalculateLiveResponse() }
|
||||
.asSequence()
|
||||
.map {
|
||||
val canUsageStr = when (it.canUsage) {
|
||||
CanUsage.LIVE -> {
|
||||
"유료"
|
||||
}
|
||||
|
||||
CanUsage.SPIN_ROULETTE -> {
|
||||
"룰렛"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"후원"
|
||||
}
|
||||
}
|
||||
|
||||
val numberOfPeople = if (it.canUsage == CanUsage.LIVE) {
|
||||
it.memberCount.toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = it.totalAmount * 100
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw * 0.066f
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = (totalKrw.toFloat() - paymentFee) * 0.7
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount * 0.033
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount - tax
|
||||
|
||||
GetCalculateLiveResponse(
|
||||
email = it.email,
|
||||
nickname = it.nickname,
|
||||
date = it.date,
|
||||
title = it.title,
|
||||
entranceFee = it.entranceFee,
|
||||
canUsageStr = canUsageStr,
|
||||
numberOfPeople = numberOfPeople,
|
||||
totalAmount = it.totalAmount,
|
||||
totalKrw = totalKrw,
|
||||
paymentFee = paymentFee.roundToInt(),
|
||||
settlementAmount = settlementAmount.roundToInt(),
|
||||
tax = tax.roundToInt(),
|
||||
depositAmount = depositAmount.roundToInt()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -28,12 +93,59 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
|
||||
key = "'calculateContent:' + " + "#startDateStr + ':' + #endDateStr"
|
||||
)
|
||||
fun getCalculateContentList(startDateStr: String, endDateStr: String): List<GetCalculateContentResponse> {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository
|
||||
.getCalculateContentList(startDate, endDate)
|
||||
.map { it.toGetCalculateContentResponse() }
|
||||
.asSequence()
|
||||
.map {
|
||||
val orderTypeStr = if (it.orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = it.totalCan * 100
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw * 0.066f
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = (totalKrw.toFloat() - paymentFee) * 0.7
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount * 0.033
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount - tax
|
||||
|
||||
GetCalculateContentResponse(
|
||||
nickname = it.nickname,
|
||||
title = it.title,
|
||||
registrationDate = it.registrationDate,
|
||||
saleDate = it.saleDate,
|
||||
orderType = orderTypeStr,
|
||||
orderPrice = it.orderPrice,
|
||||
numberOfPeople = it.numberOfPeople.toInt(),
|
||||
totalCan = it.totalCan,
|
||||
totalKrw = totalKrw,
|
||||
paymentFee = paymentFee.roundToInt(),
|
||||
settlementAmount = settlementAmount.roundToInt(),
|
||||
tax = tax.roundToInt(),
|
||||
depositAmount = depositAmount.roundToInt()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -45,7 +157,45 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
|
||||
val totalCount = repository.getCumulativeSalesByContentTotalCount()
|
||||
val items = repository
|
||||
.getCumulativeSalesByContent(offset, limit)
|
||||
.map { it.toCumulativeSalesByContentItem() }
|
||||
.asSequence()
|
||||
.map {
|
||||
val orderTypeStr = if (it.orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = it.totalCan * 100
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw * 0.066f
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = (totalKrw.toFloat() - paymentFee) * 0.7
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount * 0.033
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount - tax
|
||||
|
||||
CumulativeSalesByContentItem(
|
||||
nickname = it.nickname,
|
||||
title = it.title,
|
||||
registrationDate = it.registrationDate,
|
||||
orderType = orderTypeStr,
|
||||
orderPrice = it.orderPrice,
|
||||
numberOfPeople = it.numberOfPeople.toInt(),
|
||||
totalCan = it.totalCan,
|
||||
totalKrw = totalKrw,
|
||||
paymentFee = paymentFee.roundToInt(),
|
||||
settlementAmount = settlementAmount.roundToInt(),
|
||||
tax = tax.roundToInt(),
|
||||
depositAmount = depositAmount.roundToInt()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
return GetCumulativeSalesByContentResponse(totalCount, items)
|
||||
}
|
||||
@@ -59,84 +209,61 @@ class AdminCalculateService(private val repository: AdminCalculateQueryRepositor
|
||||
startDateStr: String,
|
||||
endDateStr: String
|
||||
): List<GetCalculateContentDonationResponse> {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val endDate = LocalDate.parse(endDateStr, dateTimeFormatter).atTime(23, 59, 59)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository
|
||||
.getCalculateContentDonationList(startDate, endDate)
|
||||
.map { it.toGetCalculateContentDonationResponse() }
|
||||
}
|
||||
.asSequence()
|
||||
.map {
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = it.totalCan * 100
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@Cacheable(
|
||||
cacheNames = ["cache_ttl_3_hours"],
|
||||
key = "'calculateCommunityPost:' + " + "#startDateStr + ':' + #endDateStr + ':' + #offset"
|
||||
)
|
||||
fun getCalculateCommunityPost(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): GetCreatorCalculateCommunityPostResponse {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw * 0.066f
|
||||
|
||||
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
|
||||
val items = repository
|
||||
.getCalculateCommunityPostList(startDate, endDate, offset, limit)
|
||||
.map { it.toGetCalculateCommunityPostResponse() }
|
||||
// 정산금액
|
||||
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
|
||||
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (it.price > 0) {
|
||||
(totalKrw.toFloat() - paymentFee) * 0.9
|
||||
} else {
|
||||
(totalKrw.toFloat() - paymentFee) * 0.7
|
||||
}
|
||||
|
||||
return GetCreatorCalculateCommunityPostResponse(totalCount, items)
|
||||
}
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount * 0.033
|
||||
|
||||
fun getCalculateLiveByCreator(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
) = run {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount - tax
|
||||
|
||||
val totalCount = repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)
|
||||
val items = repository
|
||||
.getCalculateLiveByCreator(startDate, endDate, offset, limit)
|
||||
.map { it.toGetCalculateByCreator() }
|
||||
|
||||
GetCalculateByCreatorResponse(totalCount, items)
|
||||
}
|
||||
|
||||
fun getCalculateContentByCreator(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
) = run {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
|
||||
val totalCount = repository.getCalculateContentByCreatorTotalCount(startDate, endDate)
|
||||
val items = repository
|
||||
.getCalculateContentByCreator(startDate, endDate, offset, limit)
|
||||
.map { it.toGetCalculateByCreator() }
|
||||
|
||||
GetCalculateByCreatorResponse(totalCount, items)
|
||||
}
|
||||
|
||||
fun getCalculateCommunityByCreator(
|
||||
startDateStr: String,
|
||||
endDateStr: String,
|
||||
offset: Long,
|
||||
limit: Long
|
||||
) = run {
|
||||
val startDate = startDateStr.convertLocalDateTime()
|
||||
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
|
||||
|
||||
val totalCount = repository.getCalculateCommunityByCreatorTotalCount(startDate, endDate)
|
||||
val items = repository
|
||||
.getCalculateCommunityByCreator(startDate, endDate, offset, limit)
|
||||
.map { it.toGetCalculateByCreator() }
|
||||
|
||||
GetCalculateByCreatorResponse(totalCount, items)
|
||||
GetCalculateContentDonationResponse(
|
||||
nickname = it.nickname,
|
||||
title = it.title,
|
||||
paidOrFree = if (it.price > 0) {
|
||||
"유료"
|
||||
} else {
|
||||
"무료"
|
||||
},
|
||||
registrationDate = it.registrationDate,
|
||||
donationDate = it.donationDate,
|
||||
numberOfDonation = it.numberOfDonation.toInt(),
|
||||
totalCan = it.totalCan,
|
||||
totalKrw = totalKrw,
|
||||
paymentFee = paymentFee.roundToInt(),
|
||||
settlementAmount = settlementAmount.roundToInt(),
|
||||
tax = tax.roundToInt(),
|
||||
depositAmount = depositAmount.roundToInt()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
@@ -1,14 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class GetCalculateByCreatorItem(
|
||||
@JsonProperty("email") val email: String,
|
||||
@JsonProperty("nickname") val nickname: String,
|
||||
@JsonProperty("totalCan") val totalCan: Int,
|
||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||
@JsonProperty("tax") val tax: Int,
|
||||
@JsonProperty("depositAmount") val depositAmount: Int
|
||||
)
|
@@ -1,44 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCalculateByCreatorQueryData @QueryProjection constructor(
|
||||
val email: String,
|
||||
val nickname: String,
|
||||
val totalCan: Int,
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateByCreator(): GetCalculateByCreatorItem {
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (settlementRatio != null) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
return GetCalculateByCreatorItem(
|
||||
email = email,
|
||||
nickname = nickname,
|
||||
totalCan = totalCan,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,6 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
data class GetCalculateByCreatorResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetCalculateByCreatorItem>
|
||||
)
|
@@ -1,50 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCalculateCommunityPostQueryData @QueryProjection constructor(
|
||||
val nickname: String,
|
||||
val title: String,
|
||||
val date: String,
|
||||
val can: Int,
|
||||
val numberOfPurchase: Long,
|
||||
val totalCan: Int,
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateCommunityPostResponse(): GetCalculateCommunityPostResponse {
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (settlementRatio != null) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
return GetCalculateCommunityPostResponse(
|
||||
nickname = nickname,
|
||||
title = title,
|
||||
date = date,
|
||||
can = can,
|
||||
numberOfPurchase = numberOfPurchase.toInt(),
|
||||
totalCan = totalCan,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class GetCalculateCommunityPostResponse(
|
||||
@JsonProperty("nickname") val nickname: String,
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("date") val date: String,
|
||||
@JsonProperty("can") val can: Int,
|
||||
@JsonProperty("numberOfPurchase") val numberOfPurchase: Int,
|
||||
@JsonProperty("totalCan") val totalCan: Int,
|
||||
@JsonProperty("totalKrw") val totalKrw: Int,
|
||||
@JsonProperty("paymentFee") val paymentFee: Int,
|
||||
@JsonProperty("settlementAmount") val settlementAmount: Int,
|
||||
@JsonProperty("tax") val tax: Int,
|
||||
@JsonProperty("depositAmount") val depositAmount: Int
|
||||
)
|
@@ -1,8 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCalculateContentDonationQueryData @QueryProjection constructor(
|
||||
// 등록 크리에이터 닉네임
|
||||
@@ -19,48 +17,4 @@ data class GetCalculateContentDonationQueryData @QueryProjection constructor(
|
||||
val numberOfDonation: Long,
|
||||
// 합계
|
||||
val totalCan: Int
|
||||
) {
|
||||
fun toGetCalculateContentDonationResponse(): GetCalculateContentDonationResponse {
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액
|
||||
// 유료콘텐츠 (원화 - 결제수수료) 의 90%
|
||||
// 무료콘텐츠 (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (price > 0) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.9))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
val paidOrFree = if (price > 0) {
|
||||
"유료"
|
||||
} else {
|
||||
"무료"
|
||||
}
|
||||
|
||||
return GetCalculateContentDonationResponse(
|
||||
nickname = nickname,
|
||||
title = title,
|
||||
paidOrFree = paidOrFree,
|
||||
registrationDate = registrationDate,
|
||||
donationDate = donationDate,
|
||||
numberOfDonation = numberOfDonation.toInt(),
|
||||
totalCan = totalCan,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCalculateContentQueryData @QueryProjection constructor(
|
||||
// 등록 크리에이터 닉네임
|
||||
@@ -21,53 +19,5 @@ data class GetCalculateContentQueryData @QueryProjection constructor(
|
||||
// 인원
|
||||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateContentResponse(): GetCalculateContentResponse {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (settlementRatio != null) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
return GetCalculateContentResponse(
|
||||
nickname = nickname,
|
||||
title = title,
|
||||
registrationDate = registrationDate,
|
||||
saleDate = saleDate,
|
||||
orderType = orderTypeStr,
|
||||
orderPrice = orderPrice,
|
||||
numberOfPeople = numberOfPeople.toInt(),
|
||||
totalCan = totalCan,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
val totalCan: Int
|
||||
)
|
||||
|
@@ -2,8 +2,6 @@ package kr.co.vividnext.sodalive.admin.calculate
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCalculateLiveQueryData @QueryProjection constructor(
|
||||
val email: String,
|
||||
@@ -17,68 +15,5 @@ data class GetCalculateLiveQueryData @QueryProjection constructor(
|
||||
// 참여인원
|
||||
val memberCount: Long,
|
||||
// 합계
|
||||
val totalAmount: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toGetCalculateLiveResponse(): GetCalculateLiveResponse {
|
||||
val canUsageStr = when (canUsage) {
|
||||
CanUsage.LIVE -> {
|
||||
"유료"
|
||||
}
|
||||
|
||||
CanUsage.SPIN_ROULETTE -> {
|
||||
"룰렛"
|
||||
}
|
||||
|
||||
CanUsage.HEART -> {
|
||||
"하트"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"후원"
|
||||
}
|
||||
}
|
||||
|
||||
val numberOfPeople = if (canUsage == CanUsage.LIVE) {
|
||||
memberCount.toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalAmount).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (settlementRatio != null) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
return GetCalculateLiveResponse(
|
||||
email = email,
|
||||
nickname = nickname,
|
||||
date = date,
|
||||
title = title,
|
||||
entranceFee = entranceFee,
|
||||
canUsageStr = canUsageStr,
|
||||
numberOfPeople = numberOfPeople,
|
||||
totalAmount = totalAmount,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
val totalAmount: Int
|
||||
)
|
||||
|
@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.admin.calculate
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
||||
// 등록 크리에이터 닉네임
|
||||
@@ -20,56 +18,8 @@ data class GetCumulativeSalesByContentQueryData @QueryProjection constructor(
|
||||
// 인원
|
||||
val numberOfPeople: Long,
|
||||
// 합계
|
||||
val totalCan: Int,
|
||||
// 포인트
|
||||
val totalPoint: Int,
|
||||
// 정산비율
|
||||
val settlementRatio: Int?
|
||||
) {
|
||||
fun toCumulativeSalesByContentItem(): CumulativeSalesByContentItem {
|
||||
val orderTypeStr = if (totalPoint > 0) {
|
||||
"포인트"
|
||||
} else if (orderType == OrderType.RENTAL) {
|
||||
"대여"
|
||||
} else {
|
||||
"소장"
|
||||
}
|
||||
|
||||
// 원화 = totalCoin * 100 ( 캔 1개 = 100원 )
|
||||
val totalKrw = BigDecimal(totalCan).multiply(BigDecimal(100))
|
||||
|
||||
// 결제수수료 : 6.6%
|
||||
val paymentFee = totalKrw.multiply(BigDecimal(0.066))
|
||||
|
||||
// 정산금액 = (원화 - 결제수수료) 의 70%
|
||||
val settlementAmount = if (settlementRatio != null) {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(settlementRatio).divide(BigDecimal(100.0)))
|
||||
} else {
|
||||
totalKrw.subtract(paymentFee).multiply(BigDecimal(0.7))
|
||||
}
|
||||
|
||||
// 원천세 = 정산금액의 3.3%
|
||||
val tax = settlementAmount.multiply(BigDecimal(0.033))
|
||||
|
||||
// 입금액
|
||||
val depositAmount = settlementAmount.subtract(tax)
|
||||
|
||||
return CumulativeSalesByContentItem(
|
||||
nickname = nickname,
|
||||
title = title,
|
||||
registrationDate = registrationDate,
|
||||
orderType = orderTypeStr,
|
||||
orderPrice = orderPrice,
|
||||
numberOfPeople = numberOfPeople.toInt(),
|
||||
totalCan = totalCan,
|
||||
totalKrw = totalKrw.toInt(),
|
||||
paymentFee = paymentFee.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
settlementAmount = settlementAmount.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
tax = tax.setScale(0, RoundingMode.HALF_UP).toInt(),
|
||||
depositAmount = depositAmount.setScale(0, RoundingMode.HALF_UP).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
val totalCan: Int
|
||||
)
|
||||
|
||||
data class GetCumulativeSalesByContentResponse(
|
||||
@JsonProperty("totalCount") val totalCount: Int,
|
||||
|
@@ -1,16 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
data class CreateCreatorSettlementRatioRequest(
|
||||
val memberId: Long,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
) {
|
||||
fun toEntity() = CreatorSettlementRatio(
|
||||
subsidy = subsidy,
|
||||
liveSettlementRatio = liveSettlementRatio,
|
||||
contentSettlementRatio = contentSettlementRatio,
|
||||
communitySettlementRatio = communitySettlementRatio
|
||||
)
|
||||
}
|
@@ -1,38 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.FetchType
|
||||
import javax.persistence.JoinColumn
|
||||
import javax.persistence.OneToOne
|
||||
|
||||
@Entity
|
||||
data class CreatorSettlementRatio(
|
||||
var subsidy: Int,
|
||||
var liveSettlementRatio: Int,
|
||||
var contentSettlementRatio: Int,
|
||||
var communitySettlementRatio: Int
|
||||
) : BaseEntity() {
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "member_id", nullable = false)
|
||||
var member: Member? = null
|
||||
|
||||
var deletedAt: LocalDateTime? = null
|
||||
|
||||
fun softDelete() {
|
||||
this.deletedAt = LocalDateTime.now()
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
this.deletedAt = null
|
||||
}
|
||||
|
||||
fun updateValues(subsidy: Int, live: Int, content: Int, community: Int) {
|
||||
this.subsidy = subsidy
|
||||
this.liveSettlementRatio = live
|
||||
this.contentSettlementRatio = content
|
||||
this.communitySettlementRatio = community
|
||||
}
|
||||
}
|
@@ -1,41 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@RequestMapping("/admin/calculate/ratio")
|
||||
class CreatorSettlementRatioController(private val service: CreatorSettlementRatioService) {
|
||||
@PostMapping
|
||||
fun createCreatorSettlementRatio(
|
||||
@RequestBody request: CreateCreatorSettlementRatioRequest
|
||||
) = ApiResponse.ok(service.createCreatorSettlementRatio(request))
|
||||
|
||||
@GetMapping
|
||||
fun getCreatorSettlementRatio(
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getCreatorSettlementRatio(
|
||||
offset = pageable.offset,
|
||||
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))
|
||||
}
|
@@ -1,51 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.calculate.ratio.QCreatorSettlementRatio.creatorSettlementRatio
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface CreatorSettlementRatioRepository :
|
||||
JpaRepository<CreatorSettlementRatio, Long>,
|
||||
CreatorSettlementRatioQueryRepository {
|
||||
fun findByMemberId(memberId: Long): CreatorSettlementRatio?
|
||||
}
|
||||
|
||||
interface CreatorSettlementRatioQueryRepository {
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem>
|
||||
fun getCreatorSettlementRatioTotalCount(): Int
|
||||
}
|
||||
|
||||
class CreatorSettlementRatioQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : CreatorSettlementRatioQueryRepository {
|
||||
override fun getCreatorSettlementRatio(offset: Long, limit: Long): List<GetCreatorSettlementRatioItem> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCreatorSettlementRatioItem(
|
||||
member.id,
|
||||
member.nickname,
|
||||
creatorSettlementRatio.subsidy,
|
||||
creatorSettlementRatio.liveSettlementRatio,
|
||||
creatorSettlementRatio.contentSettlementRatio,
|
||||
creatorSettlementRatio.communitySettlementRatio
|
||||
)
|
||||
)
|
||||
.from(creatorSettlementRatio)
|
||||
.innerJoin(creatorSettlementRatio.member, member)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.orderBy(creatorSettlementRatio.id.asc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getCreatorSettlementRatioTotalCount(): Int {
|
||||
return queryFactory
|
||||
.select(creatorSettlementRatio.id)
|
||||
.from(creatorSettlementRatio)
|
||||
.where(creatorSettlementRatio.deletedAt.isNull)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class CreatorSettlementRatioService(
|
||||
private val repository: CreatorSettlementRatioRepository,
|
||||
private val memberRepository: MemberRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun createCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
if (existing != null) {
|
||||
// revive if soft-deleted, then update values
|
||||
existing.restore()
|
||||
existing.updateValues(
|
||||
request.subsidy,
|
||||
request.liveSettlementRatio,
|
||||
request.contentSettlementRatio,
|
||||
request.communitySettlementRatio
|
||||
)
|
||||
repository.save(existing)
|
||||
return
|
||||
}
|
||||
|
||||
val creatorSettlementRatio = request.toEntity()
|
||||
creatorSettlementRatio.member = creator
|
||||
repository.save(creatorSettlementRatio)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateCreatorSettlementRatio(request: CreateCreatorSettlementRatioRequest) {
|
||||
val creator = memberRepository.findByIdOrNull(request.memberId)
|
||||
?: throw SodaException("잘못된 크리에이터 입니다.")
|
||||
if (creator.role != MemberRole.CREATOR) {
|
||||
throw SodaException("잘못된 크리에이터 입니다.")
|
||||
}
|
||||
val existing = repository.findByMemberId(request.memberId)
|
||||
?: throw SodaException("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
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("해당 크리에이터의 정산 비율 설정이 없습니다.")
|
||||
existing.softDelete()
|
||||
repository.save(existing)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCreatorSettlementRatio(offset: Long, limit: Long): GetCreatorSettlementRatioResponse {
|
||||
val totalCount = repository.getCreatorSettlementRatioTotalCount()
|
||||
val items = repository.getCreatorSettlementRatio(offset, limit)
|
||||
|
||||
return GetCreatorSettlementRatioResponse(totalCount, items)
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.calculate.ratio
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetCreatorSettlementRatioResponse(
|
||||
val totalCount: Int,
|
||||
val items: List<GetCreatorSettlementRatioItem>
|
||||
)
|
||||
|
||||
data class GetCreatorSettlementRatioItem @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val subsidy: Int,
|
||||
val liveSettlementRatio: Int,
|
||||
val contentSettlementRatio: Int,
|
||||
val communitySettlementRatio: Int
|
||||
)
|
@@ -1,10 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
@@ -15,11 +13,6 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/admin/can")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminCanController(private val service: AdminCanService) {
|
||||
@GetMapping
|
||||
fun getCans(): ApiResponse<List<CanResponse>> {
|
||||
return ApiResponse.ok(service.getCans())
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
fun insertCan(@RequestBody request: AdminCanRequest) = ApiResponse.ok(service.saveCan(request))
|
||||
|
||||
|
@@ -1,38 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
import kr.co.vividnext.sodalive.can.QCanResponse
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>, AdminCanQueryRepository
|
||||
|
||||
interface AdminCanQueryRepository {
|
||||
fun findAllByStatus(status: CanStatus): List<CanResponse>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : AdminCanQueryRepository {
|
||||
override fun findAllByStatus(status: CanStatus): List<CanResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QCanResponse(
|
||||
can1.id,
|
||||
can1.title,
|
||||
can1.can,
|
||||
can1.rewardCan,
|
||||
can1.price.intValue(),
|
||||
can1.currency,
|
||||
can1.price.stringValue()
|
||||
)
|
||||
)
|
||||
.from(can1)
|
||||
.where(can1.status.eq(status))
|
||||
.orderBy(can1.currency.asc(), can1.price.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
interface AdminCanRepository : JpaRepository<Can, Long>
|
||||
|
@@ -3,13 +3,11 @@ package kr.co.vividnext.sodalive.admin.can
|
||||
import kr.co.vividnext.sodalive.can.Can
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.extensions.moneyFormat
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class AdminCanRequest(
|
||||
val can: Int,
|
||||
val rewardCan: Int,
|
||||
val price: BigDecimal,
|
||||
val currency: String
|
||||
val price: Int
|
||||
) {
|
||||
fun toEntity(): Can {
|
||||
var title = "${can.moneyFormat()} 캔"
|
||||
@@ -22,7 +20,6 @@ data class AdminCanRequest(
|
||||
can = can,
|
||||
rewardCan = rewardCan,
|
||||
price = price,
|
||||
currency = currency,
|
||||
status = CanStatus.SALE
|
||||
)
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.can
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.member.AdminMemberRepository
|
||||
import kr.co.vividnext.sodalive.can.CanResponse
|
||||
import kr.co.vividnext.sodalive.can.CanStatus
|
||||
import kr.co.vividnext.sodalive.can.charge.Charge
|
||||
import kr.co.vividnext.sodalive.can.charge.ChargeRepository
|
||||
@@ -21,10 +20,6 @@ class AdminCanService(
|
||||
private val chargeRepository: ChargeRepository,
|
||||
private val memberRepository: AdminMemberRepository
|
||||
) {
|
||||
fun getCans(): List<CanResponse> {
|
||||
return repository.findAllByStatus(status = CanStatus.SALE)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun saveCan(request: AdminCanRequest) {
|
||||
repository.save(request.toEntity())
|
||||
|
@@ -21,7 +21,6 @@ class AdminChargeStatusController(private val service: AdminChargeStatusService)
|
||||
@GetMapping("/detail")
|
||||
fun getChargeStatusDetail(
|
||||
@RequestParam startDateStr: String,
|
||||
@RequestParam paymentGateway: PaymentGateway,
|
||||
@RequestParam(value = "currency", required = false) currency: String? = null
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway, currency))
|
||||
@RequestParam paymentGateway: PaymentGateway
|
||||
) = ApiResponse.ok(service.getChargeStatusDetail(startDateStr, paymentGateway))
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.BooleanBuilder
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.can.QCan.can1
|
||||
@@ -15,7 +14,7 @@ import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory) {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
fun getChargeStatus(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
Expressions.dateTimeTemplate(
|
||||
@@ -27,16 +26,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
val currency = Expressions.stringTemplate("substring({0}, length({0}) - 2, 3)", payment.locale)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
QGetChargeStatusQueryDto(
|
||||
formattedDate,
|
||||
payment.price.sum(),
|
||||
can1.price.sum(),
|
||||
payment.id.count(),
|
||||
payment.paymentGateway.stringValue(),
|
||||
currency.coalesce("KRW")
|
||||
payment.paymentGateway
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
@@ -48,46 +46,15 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(formattedDate, payment.paymentGateway, currency.coalesce("KRW"))
|
||||
.groupBy(formattedDate, payment.paymentGateway)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusSummary(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusResponse> {
|
||||
val currency = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetChargeStatusResponse(
|
||||
Expressions.stringTemplate("'합계'"), // date
|
||||
payment.price.sum(),
|
||||
payment.id.count(),
|
||||
Expressions.stringTemplate("''"),
|
||||
currency
|
||||
)
|
||||
)
|
||||
.from(payment)
|
||||
.innerJoin(payment.charge, charge)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
)
|
||||
.groupBy(currency)
|
||||
.orderBy(currency.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDate: LocalDateTime,
|
||||
endDate: LocalDateTime,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailQueryDto> {
|
||||
val formattedDate = Expressions.stringTemplate(
|
||||
"DATE_FORMAT({0}, {1})",
|
||||
@@ -100,20 +67,6 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
),
|
||||
"%Y-%m-%d %H:%i:%s"
|
||||
)
|
||||
val currencyExpr = Expressions.stringTemplate(
|
||||
"substring({0}, length({0}) - 2, 3)",
|
||||
payment.locale
|
||||
).coalesce("KRW")
|
||||
val whereBuilder = BooleanBuilder()
|
||||
whereBuilder.and(charge.createdAt.goe(startDate))
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
|
||||
if (currency != null) {
|
||||
whereBuilder.and(currencyExpr.eq(currency))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
@@ -122,7 +75,8 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
member.nickname,
|
||||
payment.method.coalesce(""),
|
||||
payment.price,
|
||||
currencyExpr,
|
||||
can1.price,
|
||||
payment.locale.coalesce(""),
|
||||
formattedDate
|
||||
)
|
||||
)
|
||||
@@ -130,7 +84,13 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
|
||||
.innerJoin(charge.member, member)
|
||||
.innerJoin(charge.payment, payment)
|
||||
.leftJoin(charge.can, can1)
|
||||
.where(whereBuilder)
|
||||
.where(
|
||||
charge.createdAt.goe(startDate)
|
||||
.and(charge.createdAt.loe(endDate))
|
||||
.and(charge.status.eq(ChargeStatus.CHARGE))
|
||||
.and(payment.status.eq(PaymentStatus.COMPLETE))
|
||||
.and(payment.paymentGateway.eq(paymentGateway))
|
||||
)
|
||||
.orderBy(formattedDate.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
@@ -20,17 +20,48 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
val summaryRows = repository.getChargeStatusSummary(startDate, endDate)
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate).toMutableList()
|
||||
chargeStatusList.addAll(0, summaryRows)
|
||||
var totalChargeAmount = 0
|
||||
var totalChargeCount = 0L
|
||||
|
||||
val chargeStatusList = repository.getChargeStatus(startDate, endDate)
|
||||
.asSequence()
|
||||
.map {
|
||||
val chargeAmount = if (it.paymentGateWay == PaymentGateway.PG) {
|
||||
it.pgChargeAmount
|
||||
} else {
|
||||
it.appleChargeAmount.toInt()
|
||||
}
|
||||
|
||||
val chargeCount = it.chargeCount
|
||||
|
||||
totalChargeAmount += chargeAmount
|
||||
totalChargeCount += chargeCount
|
||||
|
||||
GetChargeStatusResponse(
|
||||
date = it.date,
|
||||
chargeAmount = chargeAmount,
|
||||
chargeCount = chargeCount,
|
||||
pg = it.paymentGateWay.name
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
|
||||
chargeStatusList.add(
|
||||
0,
|
||||
GetChargeStatusResponse(
|
||||
date = "합계",
|
||||
chargeAmount = totalChargeAmount,
|
||||
chargeCount = totalChargeCount,
|
||||
pg = ""
|
||||
)
|
||||
)
|
||||
|
||||
return chargeStatusList.toList()
|
||||
}
|
||||
|
||||
fun getChargeStatusDetail(
|
||||
startDateStr: String,
|
||||
paymentGateway: PaymentGateway,
|
||||
currency: String? = null
|
||||
paymentGateway: PaymentGateway
|
||||
): List<GetChargeStatusDetailResponse> {
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
val startDate = LocalDate.parse(startDateStr, dateTimeFormatter).atTime(0, 0, 0)
|
||||
@@ -43,16 +74,18 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
|
||||
.withZoneSameInstant(ZoneId.of("UTC"))
|
||||
.toLocalDateTime()
|
||||
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
|
||||
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway)
|
||||
.asSequence()
|
||||
.map {
|
||||
GetChargeStatusDetailResponse(
|
||||
memberId = it.memberId,
|
||||
nickname = it.nickname,
|
||||
method = it.method,
|
||||
amount = it.amount,
|
||||
amount = it.appleChargeAmount.toInt(),
|
||||
locale = it.locale,
|
||||
datetime = it.datetime
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailQueryDto @QueryProjection constructor(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
@@ -1,12 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusDetailResponse(
|
||||
val memberId: Long,
|
||||
val nickname: String,
|
||||
val method: String,
|
||||
val amount: BigDecimal,
|
||||
val amount: Int,
|
||||
val locale: String,
|
||||
val datetime: String
|
||||
)
|
||||
|
@@ -0,0 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
|
||||
|
||||
data class GetChargeStatusQueryDto @QueryProjection constructor(
|
||||
val date: String,
|
||||
val appleChargeAmount: Double,
|
||||
val pgChargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val paymentGateWay: PaymentGateway
|
||||
)
|
@@ -1,12 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.charge
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import java.math.BigDecimal
|
||||
|
||||
data class GetChargeStatusResponse @QueryProjection constructor(
|
||||
data class GetChargeStatusResponse(
|
||||
val date: String,
|
||||
val chargeAmount: BigDecimal,
|
||||
val chargeAmount: Int,
|
||||
val chargeCount: Long,
|
||||
val pg: String,
|
||||
val currency: String
|
||||
val pg: String
|
||||
)
|
||||
|
@@ -1,229 +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.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,
|
||||
|
||||
@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 (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 수정 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)
|
||||
|
||||
ApiResponse.ok("배너가 성공적으로 삭제되었습니다.")
|
||||
}
|
||||
|
||||
/**
|
||||
* 배너 정렬 순서 일괄 변경 API
|
||||
* ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다.
|
||||
*
|
||||
* @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록)
|
||||
* @return 성공 메시지
|
||||
*/
|
||||
@PutMapping("/orders")
|
||||
fun updateBannerOrders(
|
||||
@RequestBody request: UpdateBannerOrdersRequest
|
||||
) = run {
|
||||
bannerService.updateBannerOrders(request.ids)
|
||||
|
||||
ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.")
|
||||
}
|
||||
}
|
@@ -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("끝 날짜는 오늘 날짜까지만 입력 가능합니다.")
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
throw SodaException("시작 날짜는 끝 날짜보다 이후일 수 없습니다.")
|
||||
}
|
||||
if (endDate.isAfter(startDate.plusMonths(6))) {
|
||||
throw SodaException("조회 가능 기간은 최대 6개월입니다.")
|
||||
}
|
||||
|
||||
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,423 +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.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
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,
|
||||
|
||||
@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!!)
|
||||
}
|
||||
|
||||
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) {
|
||||
throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
|
||||
// success가 true이면 data.id 반환
|
||||
return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
|
||||
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("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐릭터 수정 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("변경된 데이터가 없습니다.")
|
||||
}
|
||||
|
||||
// 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
|
||||
if (hasChangedData) {
|
||||
val chatCharacter = service.findById(request.id)
|
||||
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")
|
||||
|
||||
// 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인
|
||||
if (request.name != null && request.name != chatCharacter.name) {
|
||||
val existingCharacter = service.findByName(request.name)
|
||||
if (existingCharacter != null) {
|
||||
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
|
||||
}
|
||||
}
|
||||
|
||||
callExternalApiForUpdate(chatCharacter.characterUUID, request)
|
||||
}
|
||||
|
||||
// 이미지 경로 변수 초기화
|
||||
// 이미지가 있으면 이미지 저장
|
||||
val imagePath = if (hasImage) {
|
||||
saveImage(
|
||||
characterId = request.id,
|
||||
image = image!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// 엔티티 수정
|
||||
service.updateChatCharacterWithDetails(
|
||||
imagePath = imagePath,
|
||||
request = request
|
||||
)
|
||||
|
||||
// 원작 연결: 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) {
|
||||
throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.")
|
||||
}
|
||||
}
|
||||
}
|
@@ -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("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
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("큐레이션을 찾을 수 없습니다: ${request.id}") }
|
||||
|
||||
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("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
curation.isActive = false
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorder(ids: List<Long>) {
|
||||
ids.forEachIndexed { index, id ->
|
||||
val curation = curationRepository.findById(id)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") }
|
||||
curation.sortOrder = index + 1
|
||||
curationRepository.save(curation)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다")
|
||||
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId")
|
||||
|
||||
val uniqueIds = characterIds.filter { it > 0 }.distinct()
|
||||
if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다")
|
||||
|
||||
// 활성 캐릭터만 조회 (조회 단계에서 검증 포함)
|
||||
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("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val target = mappings.firstOrNull { it.chatCharacter.id == characterId }
|
||||
?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId")
|
||||
mappingRepository.delete(target)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun reorderCharacters(curationId: Long, characterIds: List<Long>) {
|
||||
val curation = curationRepository.findById(curationId)
|
||||
.orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
val mappings = mappingRepository.findByCuration(curation)
|
||||
val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id }
|
||||
|
||||
characterIds.forEachIndexed { index, cid ->
|
||||
val mapping = mappingByCharacterId[cid]
|
||||
?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid")
|
||||
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("큐레이션을 찾을 수 없습니다: $curationId") }
|
||||
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,170 +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.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,
|
||||
|
||||
@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)
|
||||
ApiResponse.ok(null, "이미지가 삭제되었습니다.")
|
||||
}
|
||||
|
||||
@PutMapping("/orders")
|
||||
fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run {
|
||||
if (request.characterId == null) throw SodaException("characterId는 필수입니다")
|
||||
imageService.updateOrders(request.characterId, request.ids)
|
||||
ApiResponse.ok(null, "정렬 순서가 변경되었습니다.")
|
||||
}
|
||||
|
||||
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("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
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("이미지 포맷을 인식할 수 없습니다.")
|
||||
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("블러 이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
@@ -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("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") }
|
||||
|
||||
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 (e: Exception) {
|
||||
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
@@ -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,213 +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 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
|
||||
) {
|
||||
|
||||
/** 원작 등록 (중복 제목 방지 포함) */
|
||||
@Transactional
|
||||
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
|
||||
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
|
||||
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.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))
|
||||
}
|
||||
}
|
||||
return originalWorkRepository.save(entity)
|
||||
}
|
||||
|
||||
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
|
||||
@Transactional
|
||||
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
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
|
||||
}
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 이미지 경로만 별도 갱신 */
|
||||
@Transactional
|
||||
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
ow.imagePath = imagePath
|
||||
return originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 삭제 (소프트 삭제) */
|
||||
@Transactional
|
||||
fun deleteOriginalWork(id: Long) {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
|
||||
ow.isDeleted = true
|
||||
originalWorkRepository.save(ow)
|
||||
}
|
||||
|
||||
/** 원작 상세 조회 (소프트 삭제 제외) */
|
||||
@Transactional(readOnly = true)
|
||||
fun getOriginalWork(id: Long): OriginalWork {
|
||||
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
}
|
||||
|
||||
/** 원작 페이징 조회 */
|
||||
@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("해당 원작을 찾을 수 없습니다") }
|
||||
|
||||
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("해당 원작을 찾을 수 없습니다") }
|
||||
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("해당 원작을 찾을 수 없습니다") }
|
||||
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("해당 캐릭터를 찾을 수 없습니다") }
|
||||
|
||||
if (originalWorkId == 0L) {
|
||||
character.originalWork = null
|
||||
} else {
|
||||
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
|
||||
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
|
||||
character.originalWork = ow
|
||||
}
|
||||
|
||||
chatCharacterRepository.save(character)
|
||||
}
|
||||
}
|
@@ -15,42 +15,16 @@ import org.springframework.web.bind.annotation.RestController
|
||||
@RequestMapping("/admin/audio-content")
|
||||
class AdminContentController(private val service: AdminContentService) {
|
||||
@GetMapping("/list")
|
||||
fun getAudioContentList(
|
||||
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.getAudioContentList(
|
||||
status = status ?: ContentReleaseStatus.OPEN,
|
||||
pageable
|
||||
)
|
||||
)
|
||||
fun getAudioContentList(pageable: Pageable) = ApiResponse.ok(service.getAudioContentList(pageable))
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchAudioContent(
|
||||
@RequestParam(value = "status", required = false) status: ContentReleaseStatus?,
|
||||
@RequestParam(value = "search_word") searchWord: String,
|
||||
pageable: Pageable
|
||||
) = ApiResponse.ok(
|
||||
service.searchAudioContent(
|
||||
status = status ?: ContentReleaseStatus.OPEN,
|
||||
searchWord,
|
||||
pageable
|
||||
)
|
||||
)
|
||||
) = ApiResponse.ok(service.searchAudioContent(searchWord, pageable))
|
||||
|
||||
@PutMapping
|
||||
fun modifyAudioContent(
|
||||
@RequestBody request: UpdateAdminContentRequest
|
||||
) = ApiResponse.ok(service.updateAudioContent(request))
|
||||
|
||||
@GetMapping("/main/tab")
|
||||
fun getContentMainTabList() = ApiResponse.ok(service.getContentMainTabList())
|
||||
}
|
||||
|
||||
enum class ContentReleaseStatus {
|
||||
// 콘텐츠가 공개된 상태
|
||||
OPEN,
|
||||
|
||||
// 예약된 콘텐츠, 아직 공개되지 않은 상태
|
||||
SCHEDULED
|
||||
}
|
||||
|
@@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.content.hashtag.QAudioContentHashTag.audioConten
|
||||
import kr.co.vividnext.sodalive.content.hashtag.QHashTag.hashTag
|
||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
@@ -19,34 +18,21 @@ import java.time.LocalDateTime
|
||||
interface AdminContentRepository : JpaRepository<AudioContent, Long>, AdminAudioContentQueryRepository
|
||||
|
||||
interface AdminAudioContentQueryRepository {
|
||||
fun getAudioContentTotalCount(
|
||||
searchWord: String = "",
|
||||
status: ContentReleaseStatus = ContentReleaseStatus.OPEN
|
||||
): Int
|
||||
|
||||
fun getAudioContentTotalCount(searchWord: String = ""): Int
|
||||
fun getAudioContentList(
|
||||
status: ContentReleaseStatus = ContentReleaseStatus.OPEN,
|
||||
imageHost: String,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
searchWord: String = ""
|
||||
): List<GetAdminContentListItem>
|
||||
|
||||
fun getHashTagList(audioContentId: Long): List<String>
|
||||
fun findByIdAndActiveTrue(audioContentId: Long): AudioContent?
|
||||
}
|
||||
|
||||
class AdminAudioContentQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : AdminAudioContentQueryRepository {
|
||||
override fun getAudioContentTotalCount(
|
||||
searchWord: String,
|
||||
status: ContentReleaseStatus
|
||||
): Int {
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
override fun getAudioContentTotalCount(searchWord: String): Int {
|
||||
var where = audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
|
||||
@@ -58,12 +44,6 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
where = if (status == ContentReleaseStatus.SCHEDULED) {
|
||||
where.and(audioContent.releaseDate.after(now))
|
||||
} else {
|
||||
where.and(audioContent.releaseDate.before(now))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(audioContent)
|
||||
@@ -73,13 +53,11 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
}
|
||||
|
||||
override fun getAudioContentList(
|
||||
status: ContentReleaseStatus,
|
||||
imageHost: String,
|
||||
offset: Long,
|
||||
limit: Long,
|
||||
searchWord: String
|
||||
): List<GetAdminContentListItem> {
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
var where = audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
|
||||
@@ -91,12 +69,6 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
where = if (status == ContentReleaseStatus.SCHEDULED) {
|
||||
where.and(audioContent.releaseDate.after(now))
|
||||
} else {
|
||||
where.and(audioContent.releaseDate.before(now))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminContentListItem(
|
||||
@@ -140,21 +112,10 @@ class AdminAudioContentQueryRepositoryImpl(
|
||||
audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContentHashTag.audioContent.id.eq(audioContentId))
|
||||
.and(audioContentHashTag.isActive.isTrue)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findByIdAndActiveTrue(audioContentId: Long): AudioContent? {
|
||||
return queryFactory
|
||||
.selectFrom(audioContent)
|
||||
.where(
|
||||
audioContent.id.eq(audioContentId),
|
||||
audioContent.isActive.isTrue
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
private fun formattedDateExpression(
|
||||
dateTime: DateTimePath<LocalDateTime>,
|
||||
format: String = "%Y-%m-%d"
|
||||
|
@@ -1,11 +1,10 @@
|
||||
package kr.co.vividnext.sodalive.admin.content
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.AdminContentCurationRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.theme.AdminContentThemeRepository
|
||||
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.main.tab.GetContentMainTabItem
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -17,17 +16,20 @@ class AdminContentService(
|
||||
private val themeRepository: AdminContentThemeRepository,
|
||||
private val audioContentCloudFront: AudioContentCloudFront,
|
||||
private val curationRepository: AdminContentCurationRepository,
|
||||
private val contentMainTabRepository: AdminContentMainTabRepository
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
) {
|
||||
fun getAudioContentList(status: ContentReleaseStatus, pageable: Pageable): GetAdminContentListResponse {
|
||||
val totalCount = repository.getAudioContentTotalCount(status = status)
|
||||
fun getAudioContentList(pageable: Pageable): GetAdminContentListResponse {
|
||||
val totalCount = repository.getAudioContentTotalCount()
|
||||
val audioContentAndThemeList = repository.getAudioContentList(
|
||||
status = status,
|
||||
imageHost = coverImageHost,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong()
|
||||
)
|
||||
|
||||
val audioContentList = audioContentAndThemeList
|
||||
.asSequence()
|
||||
.map {
|
||||
val tags = repository
|
||||
.getHashTagList(audioContentId = it.audioContentId)
|
||||
@@ -42,25 +44,23 @@ class AdminContentService(
|
||||
)
|
||||
it
|
||||
}
|
||||
.toList()
|
||||
|
||||
return GetAdminContentListResponse(totalCount, audioContentList)
|
||||
}
|
||||
|
||||
fun searchAudioContent(
|
||||
status: ContentReleaseStatus,
|
||||
searchWord: String,
|
||||
pageable: Pageable
|
||||
): GetAdminContentListResponse {
|
||||
fun searchAudioContent(searchWord: String, pageable: Pageable): GetAdminContentListResponse {
|
||||
if (searchWord.length < 2) throw SodaException("2글자 이상 입력하세요.")
|
||||
val totalCount = repository.getAudioContentTotalCount(searchWord, status = status)
|
||||
val totalCount = repository.getAudioContentTotalCount(searchWord)
|
||||
val audioContentAndThemeList = repository.getAudioContentList(
|
||||
status = status,
|
||||
imageHost = coverImageHost,
|
||||
offset = pageable.offset,
|
||||
limit = pageable.pageSize.toLong(),
|
||||
searchWord = searchWord
|
||||
)
|
||||
|
||||
val audioContentList = audioContentAndThemeList
|
||||
.asSequence()
|
||||
.map {
|
||||
val tags = repository
|
||||
.getHashTagList(audioContentId = it.audioContentId)
|
||||
@@ -75,6 +75,7 @@ class AdminContentService(
|
||||
)
|
||||
it
|
||||
}
|
||||
.toList()
|
||||
|
||||
return GetAdminContentListResponse(totalCount, audioContentList)
|
||||
}
|
||||
@@ -85,7 +86,7 @@ class AdminContentService(
|
||||
?: throw SodaException("없는 콘텐츠 입니다.")
|
||||
|
||||
if (request.isDefaultCoverImage) {
|
||||
audioContent.coverImage = "`profile/default_profile.png`"
|
||||
audioContent.coverImage = "profile/default_profile.png"
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
@@ -121,8 +122,4 @@ class AdminContentService(
|
||||
audioContent.theme = theme
|
||||
}
|
||||
}
|
||||
|
||||
fun getContentMainTabList(): List<GetContentMainTabItem> {
|
||||
return contentMainTabRepository.findAllByActiveIsTrue()
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ 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
|
||||
@@ -34,7 +33,5 @@ class AdminContentBannerController(private val service: AdminContentBannerServic
|
||||
) = ApiResponse.ok(service.updateBannerOrders(request.ids), "수정되었습니다.")
|
||||
|
||||
@GetMapping
|
||||
fun getAudioContentMainBannerList(
|
||||
@RequestParam(value = "tabId", required = false) tabId: Long? = null
|
||||
) = ApiResponse.ok(service.getAudioContentMainBannerList(tabId = tabId))
|
||||
fun getAudioContentMainBannerList() = ApiResponse.ok(service.getAudioContentMainBannerList())
|
||||
}
|
||||
|
@@ -3,8 +3,6 @@ package kr.co.vividnext.sodalive.admin.content.banner
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
@@ -15,7 +13,7 @@ import org.springframework.stereotype.Repository
|
||||
interface AdminContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AdminContentBannerQueryRepository
|
||||
|
||||
interface AdminContentBannerQueryRepository {
|
||||
fun getAudioContentMainBannerList(tabId: Long = 1): List<GetAdminContentBannerResponse>
|
||||
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse>
|
||||
}
|
||||
|
||||
class AdminContentBannerQueryRepositoryImpl(
|
||||
@@ -23,28 +21,17 @@ class AdminContentBannerQueryRepositoryImpl(
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) : AdminContentBannerQueryRepository {
|
||||
override fun getAudioContentMainBannerList(tabId: Long): List<GetAdminContentBannerResponse> {
|
||||
var where = audioContentBanner.isActive.isTrue
|
||||
|
||||
where = if (tabId <= 1L) {
|
||||
where.and(audioContentMainTab.id.isNull)
|
||||
} else {
|
||||
where.and(audioContentMainTab.id.eq(tabId))
|
||||
}
|
||||
|
||||
override fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminContentBannerResponse(
|
||||
audioContentBanner.id,
|
||||
audioContentBanner.tab.id.coalesce(1),
|
||||
audioContentBanner.type,
|
||||
audioContentBanner.thumbnailImage.prepend("/").prepend(cloudFrontHost),
|
||||
audioContentBanner.event.id,
|
||||
audioContentBanner.event.thumbnailImage,
|
||||
audioContentBanner.creator.id,
|
||||
audioContentBanner.creator.nickname,
|
||||
audioContentBanner.series.id,
|
||||
audioContentBanner.series.title,
|
||||
audioContentBanner.link,
|
||||
audioContentBanner.isAdult
|
||||
)
|
||||
@@ -52,9 +39,7 @@ class AdminContentBannerQueryRepositoryImpl(
|
||||
.from(audioContentBanner)
|
||||
.leftJoin(audioContentBanner.event, event)
|
||||
.leftJoin(audioContentBanner.creator, member)
|
||||
.leftJoin(audioContentBanner.series, series)
|
||||
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
||||
.where(where)
|
||||
.where(audioContentBanner.isActive.isTrue)
|
||||
.orderBy(audioContentBanner.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
@@ -1,8 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.banner
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||
@@ -21,9 +19,7 @@ class AdminContentBannerService(
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val repository: AdminContentBannerRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val eventRepository: EventRepository,
|
||||
private val contentMainTabRepository: AdminContentMainTabRepository,
|
||||
private val objectMapper: ObjectMapper,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
@@ -36,10 +32,6 @@ class AdminContentBannerService(
|
||||
throw SodaException("크리에이터를 선택하세요.")
|
||||
}
|
||||
|
||||
if (request.type == AudioContentBannerType.SERIES && request.seriesId == null) {
|
||||
throw SodaException("시리즈를 선택하세요.")
|
||||
}
|
||||
|
||||
if (request.type == AudioContentBannerType.LINK && request.link == null) {
|
||||
throw SodaException("링크 url을 입력하세요.")
|
||||
}
|
||||
@@ -60,25 +52,11 @@ class AdminContentBannerService(
|
||||
null
|
||||
}
|
||||
|
||||
val series = if (request.seriesId != null && request.seriesId > 0) {
|
||||
seriesRepository.findByIdOrNull(request.seriesId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val tab = if (request.tabId !== null) {
|
||||
contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val audioContentBanner = AudioContentBanner(type = request.type)
|
||||
audioContentBanner.link = request.link
|
||||
audioContentBanner.isAdult = request.isAdult
|
||||
audioContentBanner.event = event
|
||||
audioContentBanner.creator = creator
|
||||
audioContentBanner.series = series
|
||||
audioContentBanner.tab = tab
|
||||
repository.save(audioContentBanner)
|
||||
|
||||
val fileName = generateFileName()
|
||||
@@ -118,57 +96,35 @@ class AdminContentBannerService(
|
||||
audioContentBanner.creator = null
|
||||
audioContentBanner.event = null
|
||||
audioContentBanner.link = null
|
||||
audioContentBanner.series = null
|
||||
|
||||
when (request.type) {
|
||||
AudioContentBannerType.EVENT -> {
|
||||
if (request.eventId != null) {
|
||||
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||
?: throw SodaException("이벤트를 선택하세요.")
|
||||
if (request.type == AudioContentBannerType.CREATOR) {
|
||||
if (request.creatorId != null) {
|
||||
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||
?: throw SodaException("크리에이터를 선택하세요.")
|
||||
|
||||
audioContentBanner.event = event
|
||||
} else {
|
||||
throw SodaException("이벤트를 선택하세요.")
|
||||
}
|
||||
audioContentBanner.creator = creator
|
||||
} else {
|
||||
throw SodaException("크리에이터를 선택하세요.")
|
||||
}
|
||||
|
||||
AudioContentBannerType.CREATOR -> {
|
||||
if (request.creatorId != null) {
|
||||
val creator = memberRepository.findByIdOrNull(request.creatorId)
|
||||
?: throw SodaException("크리에이터를 선택하세요.")
|
||||
|
||||
audioContentBanner.creator = creator
|
||||
} else {
|
||||
throw SodaException("크리에이터를 선택하세요.")
|
||||
}
|
||||
} else if (request.type == AudioContentBannerType.LINK) {
|
||||
if (request.link != null) {
|
||||
audioContentBanner.link = request.link
|
||||
} else {
|
||||
throw SodaException("링크 url을 입력하세요.")
|
||||
}
|
||||
} else if (request.type == AudioContentBannerType.EVENT) {
|
||||
if (request.eventId != null) {
|
||||
val event = eventRepository.findByIdOrNull(request.eventId)
|
||||
?: throw SodaException("이벤트를 선택하세요.")
|
||||
|
||||
AudioContentBannerType.LINK -> {
|
||||
if (request.link != null) {
|
||||
audioContentBanner.link = request.link
|
||||
} else {
|
||||
throw SodaException("링크 url을 입력하세요.")
|
||||
}
|
||||
}
|
||||
|
||||
AudioContentBannerType.SERIES -> {
|
||||
if (request.seriesId != null) {
|
||||
val series = seriesRepository.findByIdOrNull(request.seriesId)
|
||||
?: throw SodaException("시리즈를 선택하세요.")
|
||||
|
||||
audioContentBanner.series = series
|
||||
} else {
|
||||
throw SodaException("시리즈를 선택하세요.")
|
||||
}
|
||||
audioContentBanner.event = event
|
||||
} else {
|
||||
throw SodaException("이벤트를 선택하세요.")
|
||||
}
|
||||
}
|
||||
|
||||
audioContentBanner.type = request.type
|
||||
}
|
||||
|
||||
if (request.tabId !== null) {
|
||||
audioContentBanner.tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -182,7 +138,7 @@ class AdminContentBannerService(
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioContentMainBannerList(tabId: Long?): List<GetAdminContentBannerResponse> {
|
||||
return repository.getAudioContentMainBannerList(tabId = tabId ?: 1)
|
||||
fun getAudioContentMainBannerList(): List<GetAdminContentBannerResponse> {
|
||||
return repository.getAudioContentMainBannerList()
|
||||
}
|
||||
}
|
||||
|
@@ -4,10 +4,8 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
|
||||
data class CreateContentBannerRequest(
|
||||
val type: AudioContentBannerType,
|
||||
val tabId: Long?,
|
||||
val eventId: Long?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?,
|
||||
val isAdult: Boolean
|
||||
)
|
||||
|
@@ -5,15 +5,12 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
|
||||
data class GetAdminContentBannerResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val tabId: Long?,
|
||||
val type: AudioContentBannerType,
|
||||
val thumbnailImageUrl: String,
|
||||
val eventId: Long?,
|
||||
val eventThumbnailImage: String?,
|
||||
val creatorId: Long?,
|
||||
val creatorNickname: String?,
|
||||
val seriesId: Long?,
|
||||
val seriesTitle: String?,
|
||||
val link: String?,
|
||||
val isAdult: Boolean
|
||||
)
|
||||
|
@@ -5,10 +5,8 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
data class UpdateContentBannerRequest(
|
||||
val id: Long,
|
||||
val type: AudioContentBannerType?,
|
||||
val tabId: Long?,
|
||||
val eventId: Long?,
|
||||
val creatorId: Long?,
|
||||
val seriesId: Long?,
|
||||
val link: String?,
|
||||
val isAdult: Boolean?,
|
||||
val isActive: Boolean?
|
||||
|
@@ -1,6 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
data class AddItemToCurationRequest(
|
||||
val curationId: Long,
|
||||
val itemIdList: List<Long>
|
||||
)
|
@@ -7,7 +7,6 @@ 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.RestController
|
||||
|
||||
@RestController
|
||||
@@ -30,39 +29,5 @@ class AdminContentCurationController(private val service: AdminContentCurationSe
|
||||
) = ApiResponse.ok(service.updateContentCurationOrders(request.ids), "수정되었습니다.")
|
||||
|
||||
@GetMapping
|
||||
fun getContentCurationList(
|
||||
@RequestParam tabId: Long
|
||||
) = ApiResponse.ok(service.getContentCurationList(tabId = tabId))
|
||||
|
||||
@GetMapping("/items")
|
||||
fun getCurationItems(
|
||||
@RequestParam curationId: Long
|
||||
) = ApiResponse.ok(service.getCurationItem(curationId = curationId))
|
||||
|
||||
@GetMapping("/search/content")
|
||||
fun searchCurationContentItem(
|
||||
@RequestParam curationId: Long,
|
||||
@RequestParam searchWord: String
|
||||
) = ApiResponse.ok(service.searchCurationContentItem(curationId, searchWord))
|
||||
|
||||
@GetMapping("/search/series")
|
||||
fun searchCurationSeriesItem(
|
||||
@RequestParam curationId: Long,
|
||||
@RequestParam searchWord: String
|
||||
) = ApiResponse.ok(service.searchCurationSeriesItem(curationId, searchWord))
|
||||
|
||||
@PostMapping("/add/item")
|
||||
fun addItemToCuration(
|
||||
@RequestBody request: AddItemToCurationRequest
|
||||
) = ApiResponse.ok(service.addItemToCuration(request), "큐레이션 아이템을 등록했습니다.")
|
||||
|
||||
@PutMapping("/remove/item")
|
||||
fun removeItemInCuration(
|
||||
@RequestBody request: RemoveItemInCurationRequest
|
||||
) = ApiResponse.ok(service.removeItemInCuration(request), "큐레이션 아이템을 제거했습니다.")
|
||||
|
||||
@PutMapping("/orders/item")
|
||||
fun updateItemInCurationOrders(
|
||||
@RequestBody request: UpdateCurationItemOrdersRequest
|
||||
) = ApiResponse.ok(service.updateItemInCurationOrders(request), "수정되었습니다.")
|
||||
fun getContentCurationList() = ApiResponse.ok(service.getContentCurationList())
|
||||
}
|
||||
|
@@ -1,106 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
|
||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminContentCurationItemRepository :
|
||||
JpaRepository<AudioContentCurationItem, Long>,
|
||||
AdminContentCurationItemQueryRepository
|
||||
|
||||
interface AdminContentCurationItemQueryRepository {
|
||||
fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem?
|
||||
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem?
|
||||
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem?
|
||||
fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse>
|
||||
fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse>
|
||||
}
|
||||
|
||||
class AdminContentCurationItemQueryRepositoryImpl(
|
||||
val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) : AdminContentCurationItemQueryRepository {
|
||||
override fun findByCurationIdAndSeriesId(curationId: Long, seriesId: Long?): AudioContentCurationItem? {
|
||||
return queryFactory
|
||||
.selectFrom(audioContentCurationItem)
|
||||
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||
.innerJoin(audioContentCurationItem.series, series)
|
||||
.where(
|
||||
audioContentCurationItem.curation.id.eq(curationId),
|
||||
audioContentCurationItem.series.id.eq(seriesId)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): AudioContentCurationItem? {
|
||||
return queryFactory
|
||||
.selectFrom(audioContentCurationItem)
|
||||
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||
.innerJoin(audioContentCurationItem.content, audioContent)
|
||||
.where(
|
||||
audioContentCurationItem.curation.id.eq(curationId),
|
||||
audioContentCurationItem.content.id.eq(contentId)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): AudioContentCurationItem? {
|
||||
return queryFactory.selectFrom(audioContentCurationItem)
|
||||
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||
.where(audioContentCuration.id.eq(curationId), audioContentCurationItem.id.eq(itemId))
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun getAudioContentCurationItemList(curationId: Long): List<GetCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCurationItemResponse(
|
||||
audioContentCurationItem.id,
|
||||
audioContent.title,
|
||||
audioContent.detail,
|
||||
audioContent.coverImage.prepend("/").prepend(imageHost),
|
||||
audioContent.member.nickname.coalesce(""),
|
||||
audioContent.isAdult
|
||||
)
|
||||
)
|
||||
.from(audioContentCurationItem)
|
||||
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||
.innerJoin(audioContentCurationItem.content, audioContent)
|
||||
.where(
|
||||
audioContentCuration.id.eq(curationId),
|
||||
audioContentCurationItem.isActive.isTrue
|
||||
)
|
||||
.orderBy(audioContentCurationItem.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getAudioContentCurationSeriesItemList(curationId: Long): List<GetCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetCurationItemResponse(
|
||||
audioContentCurationItem.id,
|
||||
series.title,
|
||||
series.introduction,
|
||||
series.coverImage.prepend("/").prepend(imageHost),
|
||||
series.member.nickname.coalesce(""),
|
||||
series.isAdult
|
||||
)
|
||||
)
|
||||
.from(audioContentCurationItem)
|
||||
.innerJoin(audioContentCurationItem.curation, audioContentCuration)
|
||||
.innerJoin(audioContentCurationItem.series, series)
|
||||
.where(
|
||||
audioContentCuration.id.eq(curationId),
|
||||
audioContentCurationItem.isActive.isTrue
|
||||
)
|
||||
.orderBy(audioContentCurationItem.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
}
|
@@ -1,13 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
|
||||
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@@ -17,37 +12,26 @@ interface AdminContentCurationRepository :
|
||||
AdminContentCurationQueryRepository
|
||||
|
||||
interface AdminContentCurationQueryRepository {
|
||||
fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse>
|
||||
fun getAudioContentCurationList(): List<GetAdminContentCurationResponse>
|
||||
fun findByIdAndActive(id: Long): AudioContentCuration?
|
||||
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
|
||||
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminContentCurationQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : AdminContentCurationQueryRepository {
|
||||
override fun getAudioContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
|
||||
override fun getAudioContentCurationList(): List<GetAdminContentCurationResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminContentCurationResponse(
|
||||
audioContentCuration.id,
|
||||
audioContentMainTab.id,
|
||||
audioContentCuration.title,
|
||||
audioContentCuration.description,
|
||||
audioContentCuration.isAdult,
|
||||
audioContentCuration.isSeries
|
||||
audioContentCuration.isAdult
|
||||
)
|
||||
)
|
||||
.from(audioContentCuration)
|
||||
.innerJoin(audioContentCuration.tab, audioContentMainTab)
|
||||
.where(
|
||||
audioContentCuration.isActive.isTrue,
|
||||
audioContentMainTab.id.eq(tabId)
|
||||
)
|
||||
.where(audioContentCuration.isActive.isTrue)
|
||||
.orderBy(audioContentCuration.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
@@ -61,62 +45,4 @@ class AdminContentCurationQueryRepositoryImpl(
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun searchCurationContentItem(
|
||||
curationId: Long,
|
||||
searchWord: String
|
||||
): List<SearchCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QSearchCurationItemResponse(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.coverImage.prepend("/").prepend(imageHost)
|
||||
)
|
||||
)
|
||||
.from(audioContent)
|
||||
.leftJoin(audioContentCurationItem)
|
||||
.on(
|
||||
audioContent.id.eq(audioContentCurationItem.content.id)
|
||||
.and(audioContentCurationItem.curation.id.eq(curationId))
|
||||
)
|
||||
.where(
|
||||
audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContent.isActive.isTrue)
|
||||
.and(audioContent.title.contains(searchWord))
|
||||
.and(audioContentCurationItem.id.isNull)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun searchCurationSeriesItem(
|
||||
curationId: Long,
|
||||
searchWord: String
|
||||
): List<SearchCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QSearchCurationItemResponse(
|
||||
series.id,
|
||||
series.title,
|
||||
series.coverImage.prepend("/").prepend(imageHost)
|
||||
)
|
||||
)
|
||||
.from(series)
|
||||
.leftJoin(audioContentCurationItem)
|
||||
.on(
|
||||
series.id.eq(audioContentCurationItem.series.id)
|
||||
.and(audioContentCurationItem.curation.id.eq(curationId))
|
||||
)
|
||||
.where(
|
||||
series.isActive.isTrue
|
||||
.and(series.member.isNotNull)
|
||||
.and(series.title.contains(searchWord))
|
||||
.and(
|
||||
audioContentCurationItem.id.isNull
|
||||
.or(audioContentCurationItem.isActive.isFalse)
|
||||
)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
}
|
||||
|
@@ -1,37 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.AdminContentRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationItem
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminContentCurationService(
|
||||
private val repository: AdminContentCurationRepository,
|
||||
private val contentMainTabRepository: AdminContentMainTabRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val contentRepository: AdminContentRepository,
|
||||
private val contentCurationItemRepository: AdminContentCurationItemRepository
|
||||
private val repository: AdminContentCurationRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun createContentCuration(request: CreateContentCurationRequest) {
|
||||
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
val curation = AudioContentCuration(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
isAdult = request.isAdult,
|
||||
isSeries = request.isSeries
|
||||
repository.save(
|
||||
AudioContentCuration(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
isAdult = request.isAdult
|
||||
)
|
||||
)
|
||||
curation.tab = tab
|
||||
|
||||
repository.save(curation)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -54,18 +41,6 @@ class AdminContentCurationService(
|
||||
if (request.isActive != null) {
|
||||
audioContentCuration.isActive = request.isActive
|
||||
}
|
||||
|
||||
if (request.isSeries != null) {
|
||||
audioContentCuration.isSeries = request.isSeries
|
||||
}
|
||||
|
||||
if (request.tabId != null) {
|
||||
val tab = contentMainTabRepository.findByIdOrNull(request.tabId)
|
||||
|
||||
if (tab != null) {
|
||||
audioContentCuration.tab = tab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -79,90 +54,7 @@ class AdminContentCurationService(
|
||||
}
|
||||
}
|
||||
|
||||
fun getContentCurationList(tabId: Long): List<GetAdminContentCurationResponse> {
|
||||
return repository.getAudioContentCurationList(tabId = tabId)
|
||||
}
|
||||
|
||||
fun getCurationItem(curationId: Long): List<GetCurationItemResponse> {
|
||||
val curation = repository.findByIdOrNull(curationId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
return if (curation.isSeries) {
|
||||
contentCurationItemRepository.getAudioContentCurationSeriesItemList(curationId)
|
||||
} else {
|
||||
contentCurationItemRepository.getAudioContentCurationItemList(curationId)
|
||||
}
|
||||
}
|
||||
|
||||
fun searchCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
|
||||
return repository.searchCurationContentItem(curationId, searchWord)
|
||||
}
|
||||
|
||||
fun searchCurationSeriesItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
|
||||
return repository.searchCurationSeriesItem(curationId, searchWord)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addItemToCuration(request: AddItemToCurationRequest) {
|
||||
// 큐레이션 조회
|
||||
val audioContentCuration = repository.findByIdOrNull(id = request.curationId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (audioContentCuration.isSeries) {
|
||||
request.itemIdList.forEach { seriesId ->
|
||||
val series = seriesRepository.findByIdAndActiveTrue(seriesId)
|
||||
|
||||
if (series != null) {
|
||||
val item = contentCurationItemRepository.findByCurationIdAndSeriesId(
|
||||
curationId = request.curationId,
|
||||
seriesId = series.id
|
||||
) ?: AudioContentCurationItem()
|
||||
item.curation = audioContentCuration
|
||||
item.series = series
|
||||
item.isActive = true
|
||||
contentCurationItemRepository.save(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.itemIdList.forEach { contentId ->
|
||||
val audioContent = contentRepository.findByIdAndActiveTrue(contentId)
|
||||
|
||||
if (audioContent != null) {
|
||||
val item = contentCurationItemRepository.findByCurationIdAndContentId(
|
||||
curationId = request.curationId,
|
||||
contentId = audioContent.id
|
||||
) ?: AudioContentCurationItem()
|
||||
item.curation = audioContentCuration
|
||||
item.content = audioContent
|
||||
item.isActive = true
|
||||
contentCurationItemRepository.save(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun removeItemInCuration(request: RemoveItemInCurationRequest) {
|
||||
val audioContentCurationItem = contentCurationItemRepository.findByCurationIdAndItemId(
|
||||
curationId = request.curationId,
|
||||
itemId = request.itemId
|
||||
)
|
||||
|
||||
audioContentCurationItem?.isActive = false
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateItemInCurationOrders(request: UpdateCurationItemOrdersRequest) {
|
||||
val ids = request.itemIds
|
||||
for (index in ids.indices) {
|
||||
val item = contentCurationItemRepository.findByCurationIdAndItemId(
|
||||
curationId = request.curationId,
|
||||
itemId = ids[index]
|
||||
)
|
||||
|
||||
if (item != null) {
|
||||
item.orders = index + 1
|
||||
}
|
||||
}
|
||||
fun getContentCurationList(): List<GetAdminContentCurationResponse> {
|
||||
return repository.getAudioContentCurationList()
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,16 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
data class CreateContentCurationRequest(
|
||||
val tabId: Long,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val isAdult: Boolean,
|
||||
val isSeries: Boolean
|
||||
val isAdult: Boolean
|
||||
)
|
||||
|
||||
data class UpdateContentCurationRequest(
|
||||
val id: Long,
|
||||
val tabId: Long?,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val isAdult: Boolean?,
|
||||
val isSeries: Boolean?,
|
||||
val isActive: Boolean?
|
||||
)
|
||||
|
||||
|
@@ -4,9 +4,7 @@ import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAdminContentCurationResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val tabId: Long,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val isAdult: Boolean,
|
||||
val isSeries: Boolean
|
||||
val isAdult: Boolean
|
||||
)
|
||||
|
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetCurationItemResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val desc: String,
|
||||
val coverImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val isAdult: Boolean
|
||||
)
|
@@ -1,6 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
data class RemoveItemInCurationRequest(
|
||||
val curationId: Long,
|
||||
val itemId: Long
|
||||
)
|
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class SearchCurationItemResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val coverImageUrl: String
|
||||
)
|
@@ -1,6 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation
|
||||
|
||||
data class UpdateCurationItemOrdersRequest(
|
||||
val curationId: Long,
|
||||
val itemIds: List<Long>
|
||||
)
|
@@ -1,72 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCurationItem
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCuration.contentHashTagCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCurationItem.contentHashTagCurationItem
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminContentHashTagCurationItemRepository :
|
||||
JpaRepository<ContentHashTagCurationItem, Long>,
|
||||
AdminContentHashTagCurationItemQueryRepository
|
||||
|
||||
interface AdminContentHashTagCurationItemQueryRepository {
|
||||
fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse>
|
||||
fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem?
|
||||
fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem?
|
||||
}
|
||||
|
||||
class AdminContentHashTagCurationItemQueryRepositoryImpl(
|
||||
val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) : AdminContentHashTagCurationItemQueryRepository {
|
||||
override fun getContentHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminHashTagCurationItemResponse(
|
||||
contentHashTagCurationItem.id,
|
||||
audioContent.title,
|
||||
audioContent.detail,
|
||||
audioContent.coverImage.prepend("/").prepend(imageHost),
|
||||
audioContent.member.nickname.coalesce(""),
|
||||
audioContent.isAdult
|
||||
)
|
||||
)
|
||||
.from(contentHashTagCurationItem)
|
||||
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||
.innerJoin(contentHashTagCurationItem.content, audioContent)
|
||||
.where(
|
||||
contentHashTagCuration.id.eq(curationId),
|
||||
contentHashTagCurationItem.isActive.isTrue,
|
||||
audioContent.isActive.isTrue
|
||||
)
|
||||
.orderBy(contentHashTagCurationItem.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findByCurationIdAndContentId(curationId: Long, contentId: Long?): ContentHashTagCurationItem? {
|
||||
return queryFactory
|
||||
.selectFrom(contentHashTagCurationItem)
|
||||
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||
.innerJoin(contentHashTagCurationItem.content, audioContent)
|
||||
.where(
|
||||
contentHashTagCuration.id.eq(curationId),
|
||||
audioContent.id.eq(contentId)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun findByCurationIdAndItemId(curationId: Long, itemId: Long): ContentHashTagCurationItem? {
|
||||
return queryFactory.selectFrom(contentHashTagCurationItem)
|
||||
.innerJoin(contentHashTagCurationItem.curation, contentHashTagCuration)
|
||||
.where(
|
||||
contentHashTagCuration.id.eq(curationId),
|
||||
contentHashTagCurationItem.id.eq(itemId)
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
@@ -1,63 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.AddItemToCurationRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.RemoveItemInCurationRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.UpdateCurationItemOrdersRequest
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/admin/audio-content/tag/curation")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class AdminHashTagCurationController(private val service: AdminHashTagCurationService) {
|
||||
@GetMapping
|
||||
fun getContentHashTagCurationList() = ApiResponse.ok(service.getContentHashTagCurationList())
|
||||
|
||||
@PostMapping
|
||||
fun createContentHashTagCuration(
|
||||
@RequestBody request: CreateContentHashTagCurationRequest
|
||||
) = ApiResponse.ok(service.createContentHashTagCuration(request))
|
||||
|
||||
@PutMapping
|
||||
fun updateContentHashTagCuration(
|
||||
@RequestBody request: UpdateContentHashTagCurationRequest
|
||||
) = ApiResponse.ok(service.updateContentHashTagCuration(request))
|
||||
|
||||
@PutMapping("/orders")
|
||||
fun updateContentHashTagCurationOrders(
|
||||
@RequestBody request: UpdateContentHashTagCurationOrderRequest
|
||||
) = ApiResponse.ok(service.updateContentHashTagCurationOrders(request.ids), "수정되었습니다.")
|
||||
|
||||
@GetMapping("/items")
|
||||
fun getHashTagCurationItemList(
|
||||
@RequestParam curationId: Long
|
||||
) = ApiResponse.ok(service.getHashTagCurationItemList(curationId = curationId))
|
||||
|
||||
@GetMapping("/search/content")
|
||||
fun searchHashTagCurationContentItem(
|
||||
@RequestParam curationId: Long,
|
||||
@RequestParam searchWord: String
|
||||
) = ApiResponse.ok(service.searchHashTagCurationContentItem(curationId, searchWord))
|
||||
|
||||
@PostMapping("/add/item")
|
||||
fun addItemToHashTagCuration(
|
||||
@RequestBody request: AddItemToCurationRequest
|
||||
) = ApiResponse.ok(service.addItemToHashTagCuration(request), "큐레이션 아이템을 등록했습니다.")
|
||||
|
||||
@PutMapping("/remove/item")
|
||||
fun removeItemInHashTagCuration(
|
||||
@RequestBody request: RemoveItemInCurationRequest
|
||||
) = ApiResponse.ok(service.removeItemInHashTagCuration(request), "큐레이션 아이템을 제거했습니다.")
|
||||
|
||||
@PutMapping("/orders/item")
|
||||
fun updateItemInHashTagCurationOrders(
|
||||
@RequestBody request: UpdateCurationItemOrdersRequest
|
||||
) = ApiResponse.ok(service.updateItemInHashTagCurationOrders(request), "수정되었습니다.")
|
||||
}
|
@@ -1,87 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.QSearchCurationItemResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.SearchCurationItemResponse
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCuration.contentHashTagCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.QContentHashTagCurationItem.contentHashTagCurationItem
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
interface AdminHashTagCurationRepository :
|
||||
JpaRepository<ContentHashTagCuration, Long>,
|
||||
AdminHashTagCurationQueryRepository
|
||||
|
||||
interface AdminHashTagCurationQueryRepository {
|
||||
fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse>
|
||||
fun searchHashTagCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse>
|
||||
fun isExistsTag(tag: String): Boolean
|
||||
}
|
||||
|
||||
@Repository
|
||||
class AdminHashTagCurationQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val imageHost: String
|
||||
) : AdminHashTagCurationQueryRepository {
|
||||
override fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminContentHashTagCurationResponse(
|
||||
contentHashTagCuration.id,
|
||||
contentHashTagCuration.tag,
|
||||
contentHashTagCuration.isAdult
|
||||
)
|
||||
)
|
||||
.from(contentHashTagCuration)
|
||||
.where(contentHashTagCuration.isActive.isTrue)
|
||||
.orderBy(contentHashTagCuration.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun searchHashTagCurationContentItem(
|
||||
curationId: Long,
|
||||
searchWord: String
|
||||
): List<SearchCurationItemResponse> {
|
||||
return queryFactory
|
||||
.select(
|
||||
QSearchCurationItemResponse(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.coverImage.prepend("/").prepend(imageHost)
|
||||
)
|
||||
)
|
||||
.from(audioContent)
|
||||
.leftJoin(contentHashTagCurationItem)
|
||||
.on(
|
||||
audioContent.id.eq(contentHashTagCurationItem.content.id)
|
||||
.and(contentHashTagCurationItem.curation.id.eq(curationId))
|
||||
)
|
||||
.where(
|
||||
audioContent.duration.isNotNull
|
||||
.and(audioContent.member.isNotNull)
|
||||
.and(audioContent.isActive.isTrue)
|
||||
.and(audioContent.title.contains(searchWord))
|
||||
.and(
|
||||
contentHashTagCurationItem.id.isNull
|
||||
.or(contentHashTagCurationItem.isActive.isFalse)
|
||||
)
|
||||
)
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun isExistsTag(tag: String): Boolean {
|
||||
return queryFactory
|
||||
.select(contentHashTagCuration.id)
|
||||
.from(contentHashTagCuration)
|
||||
.where(
|
||||
contentHashTagCuration.tag.eq(tag),
|
||||
contentHashTagCuration.isActive.isTrue
|
||||
)
|
||||
.fetch().isNotEmpty()
|
||||
}
|
||||
}
|
@@ -1,133 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.AddItemToCurationRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.RemoveItemInCurationRequest
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.SearchCurationItemResponse
|
||||
import kr.co.vividnext.sodalive.admin.content.curation.UpdateCurationItemOrdersRequest
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCuration
|
||||
import kr.co.vividnext.sodalive.content.main.curation.tag.ContentHashTagCurationItem
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AdminHashTagCurationService(
|
||||
private val repository: AdminHashTagCurationRepository,
|
||||
private val itemRepository: AdminContentHashTagCurationItemRepository,
|
||||
private val audioContentRepository: AudioContentRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun createContentHashTagCuration(request: CreateContentHashTagCurationRequest) {
|
||||
var tag = request.tag.trim()
|
||||
if (!tag.startsWith("#")) {
|
||||
tag = "#$tag"
|
||||
}
|
||||
|
||||
val isExists = repository.isExistsTag(tag = tag)
|
||||
|
||||
if (isExists) {
|
||||
throw SodaException("이미 등록된 태그 입니다.")
|
||||
}
|
||||
|
||||
repository.save(
|
||||
ContentHashTagCuration(
|
||||
tag = tag,
|
||||
isAdult = request.isAdult
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateContentHashTagCuration(request: UpdateContentHashTagCurationRequest) {
|
||||
val hashTagCuration = repository.findByIdOrNull(id = request.id)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
if (request.tag != null) {
|
||||
var tag = request.tag.trim()
|
||||
if (!tag.startsWith("#")) {
|
||||
tag = "#$tag"
|
||||
}
|
||||
|
||||
hashTagCuration.tag = tag
|
||||
}
|
||||
|
||||
if (request.isAdult != null) {
|
||||
hashTagCuration.isAdult = request.isAdult
|
||||
}
|
||||
|
||||
if (request.isActive != null) {
|
||||
hashTagCuration.isActive = request.isActive
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateContentHashTagCurationOrders(ids: List<Long>) {
|
||||
for (index in ids.indices) {
|
||||
val contentHashTagCuration = repository.findByIdOrNull(ids[index])
|
||||
|
||||
if (contentHashTagCuration != null) {
|
||||
contentHashTagCuration.orders = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getContentHashTagCurationList(): List<GetAdminContentHashTagCurationResponse> {
|
||||
return repository.getContentHashTagCurationList()
|
||||
}
|
||||
|
||||
fun getHashTagCurationItemList(curationId: Long): List<GetAdminHashTagCurationItemResponse> {
|
||||
return itemRepository.getContentHashTagCurationItemList(curationId)
|
||||
}
|
||||
|
||||
fun searchHashTagCurationContentItem(curationId: Long, searchWord: String): List<SearchCurationItemResponse> {
|
||||
return repository.searchHashTagCurationContentItem(curationId, searchWord)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun addItemToHashTagCuration(request: AddItemToCurationRequest) {
|
||||
val curation = repository.findByIdOrNull(id = request.curationId)
|
||||
?: throw SodaException("잘못된 요청입니다.")
|
||||
|
||||
request.itemIdList.forEach { contentId ->
|
||||
val audioContent = audioContentRepository.findByIdAndActive(contentId)
|
||||
|
||||
if (audioContent != null) {
|
||||
val item = itemRepository.findByCurationIdAndContentId(
|
||||
curationId = request.curationId,
|
||||
contentId = audioContent.id
|
||||
) ?: ContentHashTagCurationItem()
|
||||
item.curation = curation
|
||||
item.content = audioContent
|
||||
item.isActive = true
|
||||
itemRepository.save(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun removeItemInHashTagCuration(request: RemoveItemInCurationRequest) {
|
||||
val item = itemRepository.findByCurationIdAndItemId(
|
||||
curationId = request.curationId,
|
||||
itemId = request.itemId
|
||||
)
|
||||
|
||||
item?.isActive = false
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateItemInHashTagCurationOrders(request: UpdateCurationItemOrdersRequest) {
|
||||
val ids = request.itemIds
|
||||
for (index in ids.indices) {
|
||||
val item = itemRepository.findByCurationIdAndItemId(
|
||||
curationId = request.curationId,
|
||||
itemId = ids[index]
|
||||
)
|
||||
|
||||
if (item != null) {
|
||||
item.orders = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
data class CreateContentHashTagCurationRequest(
|
||||
val tag: String,
|
||||
val isAdult: Boolean
|
||||
)
|
||||
|
||||
data class UpdateContentHashTagCurationRequest(
|
||||
val id: Long,
|
||||
val tag: String?,
|
||||
val isAdult: Boolean?,
|
||||
val isActive: Boolean?
|
||||
)
|
||||
|
||||
data class UpdateContentHashTagCurationOrderRequest(
|
||||
val ids: List<Long>
|
||||
)
|
@@ -1,9 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAdminContentHashTagCurationResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val tag: String,
|
||||
val isAdult: Boolean
|
||||
)
|
@@ -1,12 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.curation.tag
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
|
||||
data class GetAdminHashTagCurationItemResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String,
|
||||
val desc: String,
|
||||
val coverImageUrl: String,
|
||||
val creatorNickname: String,
|
||||
val isAdult: Boolean
|
||||
)
|
@@ -1,22 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
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/audio-content/series")
|
||||
class AdminContentSeriesController(private val service: AdminContentSeriesService) {
|
||||
@GetMapping
|
||||
fun getSeriesList(pageable: Pageable) = ApiResponse.ok(service.getSeriesList(pageable))
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchSeriesList(
|
||||
@RequestParam(value = "search_word") searchWord: String
|
||||
) = ApiResponse.ok(service.searchSeriesList(searchWord))
|
||||
}
|
@@ -1,111 +0,0 @@
|
||||
package kr.co.vividnext.sodalive.admin.content.series
|
||||
|
||||
import com.querydsl.core.types.dsl.CaseBuilder
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.QSeriesGenre.seriesGenre
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface AdminContentSeriesRepository : JpaRepository<Series, Long>, AdminContentSeriesQueryRepository
|
||||
|
||||
interface AdminContentSeriesQueryRepository {
|
||||
fun getSeriesTotalCount(): Int
|
||||
fun getSeriesList(
|
||||
offset: Long,
|
||||
limit: Long
|
||||
): List<GetAdminSeriesListItem>
|
||||
|
||||
fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem>
|
||||
fun findByIdAndActiveTrue(seriesId: Long): Series?
|
||||
}
|
||||
|
||||
class AdminContentSeriesQueryRepositoryImpl(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val coverImageHost: String
|
||||
) : AdminContentSeriesQueryRepository {
|
||||
override fun getSeriesTotalCount(): Int {
|
||||
val where = series.isActive.isTrue
|
||||
.and(series.member.isNotNull)
|
||||
.and(series.member.isActive.isTrue)
|
||||
|
||||
return queryFactory
|
||||
.select(series.id)
|
||||
.from(series)
|
||||
.where(where)
|
||||
.fetch()
|
||||
.size
|
||||
}
|
||||
|
||||
override fun getSeriesList(offset: Long, limit: Long): List<GetAdminSeriesListItem> {
|
||||
val where = series.isActive.isTrue
|
||||
.and(series.member.isNotNull)
|
||||
.and(series.member.isActive.isTrue)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminSeriesListItem(
|
||||
series.id,
|
||||
series.title,
|
||||
series.introduction,
|
||||
series.coverImage.prepend("/").prepend(coverImageHost),
|
||||
member.nickname,
|
||||
seriesGenre.genre,
|
||||
seriesContent.id.count(),
|
||||
CaseBuilder()
|
||||
.`when`(series.state.eq(SeriesState.COMPLETE)).then("완결")
|
||||
.`when`(series.state.eq(SeriesState.COMPLETE)).then("휴재")
|
||||
.otherwise("연재중"),
|
||||
series.isAdult
|
||||
)
|
||||
)
|
||||
.from(series)
|
||||
.innerJoin(series.member, member)
|
||||
.innerJoin(series.genre, seriesGenre)
|
||||
.leftJoin(seriesContent).on(series.id.eq(seriesContent.series.id))
|
||||
.leftJoin(seriesContent.content, audioContent).on(audioContent.isActive.isTrue)
|
||||
.where(where)
|
||||
.groupBy(series.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.orderBy(series.member.id.asc(), series.orders.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun searchSeriesList(searchWord: String): List<GetAdminSearchSeriesListItem> {
|
||||
val where = series.isActive.isTrue
|
||||
.and(series.title.contains(searchWord))
|
||||
.and(series.member.isNotNull)
|
||||
.and(series.member.isActive.isTrue)
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QGetAdminSearchSeriesListItem(
|
||||
series.id,
|
||||
series.title
|
||||
)
|
||||
)
|
||||
.from(series)
|
||||
.innerJoin(series.member, member)
|
||||
.where(where)
|
||||
.orderBy(series.id.desc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun findByIdAndActiveTrue(seriesId: Long): Series? {
|
||||
return queryFactory
|
||||
.selectFrom(series)
|
||||
.where(
|
||||
series.id.eq(seriesId),
|
||||
series.isActive.isTrue
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user