Compare commits

..

2 Commits

Author SHA1 Message Date
86c627ed1d Merge pull request 'test' (#2) from test into main
Reviewed-on: #2
2023-08-18 12:54:09 +00:00
d55514e3a7 Merge pull request 'test' (#1) from test into main
Reviewed-on: #1
2023-08-16 02:30:36 +00:00
733 changed files with 1597 additions and 41502 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -26,14 +26,11 @@ 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")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.retry:spring-retry")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// jwt
@@ -47,7 +44,6 @@ dependencies {
kapt("org.springframework.boot:spring-boot-configuration-processor")
// aws
implementation("com.amazonaws:aws-java-sdk-sqs:1.12.380")
implementation("com.amazonaws:aws-java-sdk-ses:1.12.380")
implementation("com.amazonaws:aws-java-sdk-s3:1.12.380")
implementation("com.amazonaws:aws-java-sdk-cloudfront:1.12.380")
@@ -62,17 +58,6 @@ dependencies {
// firebase admin sdk
implementation("com.google.firebase:firebase-admin:9.2.0")
// 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")
runtimeOnly("com.mysql:mysql-connector-j")

View File

@@ -2,12 +2,10 @@ package kr.co.vividnext.sodalive
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.retry.annotation.EnableRetry
import org.springframework.scheduling.annotation.EnableAsync
@SpringBootApplication
@EnableAsync
@EnableRetry
class SodaLiveApplication
fun main(args: Array<String>) {

View File

@@ -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)
)
}

View File

@@ -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()
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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),
"오디션 지원이 취소 되었습니다."
)
}

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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()
)
)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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글자 입니다")
}
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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("잘못된 요청입니다.")
}
}
}

View File

@@ -1,93 +0,0 @@
package kr.co.vividnext.sodalive.admin.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/calculate")
class AdminCalculateController(private val service: AdminCalculateService) {
@GetMapping("/live")
fun getCalculateLive(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateLive(startDateStr, endDateStr))
@GetMapping("/content-list")
fun getCalculateContentList(
@RequestParam startDateStr: String,
@RequestParam endDateStr: String
) = ApiResponse.ok(service.getCalculateContentList(startDateStr, endDateStr))
@GetMapping("/cumulative-sales-by-content")
fun getCumulativeSalesByContent(pageable: Pageable) = ApiResponse.ok(
service.getCumulativeSalesByContent(pageable.offset, pageable.pageSize.toLong())
)
@GetMapping("/content-donation-list")
fun getCalculateContentDonationList(
@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()
)
)
}

View File

@@ -1,428 +0,0 @@
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
import java.time.LocalDateTime
@Repository
class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
fun getCalculateLive(startDate: LocalDateTime, endDate: LocalDateTime): List<GetCalculateLiveQueryData> {
val formattedDate = getFormattedDate(liveRoom.beginDateTime)
return queryFactory
.select(
QGetCalculateLiveQueryData(
member.email,
member.nickname,
formattedDate,
liveRoom.title,
liveRoom.price,
useCan.canUsage,
useCan.id.count(),
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(liveRoom.id, useCan.canUsage, creatorSettlementRatio.liveSettlementRatio)
.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(
member.nickname,
audioContent.title,
getFormattedDate(audioContent.createdAt),
orderFormattedDate,
order.type,
order.can,
order.id.count(),
order.can.sum(),
order.point.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(
audioContent.id,
order.type,
orderFormattedDate,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.orderBy(member.id.desc(), orderFormattedDate.desc(), audioContent.id.asc())
.fetch()
}
private fun getFormattedDate(dateTimePath: DateTimePath<LocalDateTime>): StringTemplate {
return Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
LocalDateTime::class.java,
"CONVERT_TZ({0},{1},{2})",
dateTimePath,
"UTC",
"Asia/Seoul"
),
"%Y-%m-%d"
)
}
fun getCumulativeSalesByContentTotalCount(): Int {
return queryFactory
.select(audioContent.id)
.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(
member.nickname,
audioContent.title,
getFormattedDate(audioContent.createdAt),
order.type,
order.can,
order.id.count(),
order.can.sum(),
order.point.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.isActive.isTrue)
.groupBy(
member.id,
audioContent.id,
order.type,
order.can,
pointGroup,
creatorSettlementRatio.contentSettlementRatio
)
.offset(offset)
.limit(limit)
.orderBy(member.id.desc(), audioContent.id.desc())
.fetch()
}
fun getCalculateContentDonationList(
startDate: LocalDateTime,
endDate: LocalDateTime
): List<GetCalculateContentDonationQueryData> {
val donationFormattedDate = getFormattedDate(useCan.createdAt)
return queryFactory
.select(
QGetCalculateContentDonationQueryData(
member.nickname,
audioContent.title,
audioContent.price,
getFormattedDate(audioContent.createdAt),
donationFormattedDate,
useCan.id.count(),
useCan.can.add(useCan.rewardCan).sum()
)
)
.from(useCan)
.innerJoin(useCan.audioContent, audioContent)
.innerJoin(audioContent.member, member)
.where(
useCan.isRefund.isFalse
.and(useCan.canUsage.eq(CanUsage.DONATION))
.and(useCan.createdAt.goe(startDate))
.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)
.fetch()
}
}

View File

@@ -1,142 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
import kr.co.vividnext.sodalive.creator.admin.calculate.GetCreatorCalculateCommunityPostResponse
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminCalculateService(private val repository: AdminCalculateQueryRepository) {
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
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)
return repository
.getCalculateLive(startDate, endDate)
.map { it.toGetCalculateLiveResponse() }
}
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
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)
return repository
.getCalculateContentList(startDate, endDate)
.map { it.toGetCalculateContentResponse() }
}
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'cumulativeSalesByContent:' + " + "#offset + ':' + #limit"
)
fun getCumulativeSalesByContent(offset: Long, limit: Long): GetCumulativeSalesByContentResponse {
val totalCount = repository.getCumulativeSalesByContentTotalCount()
val items = repository
.getCumulativeSalesByContent(offset, limit)
.map { it.toCumulativeSalesByContentItem() }
return GetCumulativeSalesByContentResponse(totalCount, items)
}
@Transactional(readOnly = true)
@Cacheable(
cacheNames = ["cache_ttl_3_hours"],
key = "'calculateContentDonationList2:' + " + "#startDateStr + ':' + #endDateStr"
)
fun getCalculateContentDonationList(
startDateStr: String,
endDateStr: String
): List<GetCalculateContentDonationResponse> {
val startDate = startDateStr.convertLocalDateTime()
val endDate = endDateStr.convertLocalDateTime(hour = 23, minute = 59, second = 59)
return repository
.getCalculateContentDonationList(startDate, endDate)
.map { it.toGetCalculateContentDonationResponse() }
}
@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)
val totalCount = repository.getCalculateCommunityPostTotalCount(startDate, endDate)
val items = repository
.getCalculateCommunityPostList(startDate, endDate, offset, limit)
.map { it.toGetCalculateCommunityPostResponse() }
return GetCreatorCalculateCommunityPostResponse(totalCount, items)
}
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 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)
}
}

View File

@@ -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
)

View File

@@ -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()
)
}
}

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
data class GetCalculateByCreatorResponse(
val totalCount: Int,
val items: List<GetCalculateByCreatorItem>
)

View File

@@ -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()
)
}
}

View File

@@ -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
)

View File

@@ -1,66 +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 GetCalculateContentDonationQueryData @QueryProjection constructor(
// 등록 크리에이터 닉네임
val nickname: String,
// 콘텐츠 제목
val title: String,
// 콘텐츠 가격
val price: Int,
// 콘텐츠 등록 날짜
val registrationDate: String,
// 후원 날짜
val donationDate: String,
// 인원
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()
)
}
}

View File

@@ -1,18 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateContentDonationResponse(
@JsonProperty("nickname") val nickname: String,
@JsonProperty("title") val title: String,
@JsonProperty("paidOrFree") val paidOrFree: String,
@JsonProperty("registrationDate") val registrationDate: String,
@JsonProperty("donationDate") val donationDate: String,
@JsonProperty("numberOfDonation") val numberOfDonation: 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
)

View File

@@ -1,73 +0,0 @@
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(
// 등록 크리에이터 닉네임
val nickname: String,
// 콘텐츠 제목
val title: String,
// 콘텐츠 등록 날짜
val registrationDate: String,
// 콘텐츠 판매 날짜
val saleDate: String,
// 대여/소장 구분
val orderType: OrderType,
// 판매 금액(캔)
val orderPrice: Int,
// 인원
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()
)
}
}

View File

@@ -1,19 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateContentResponse(
@JsonProperty("nickname") val nickname: String,
@JsonProperty("title") val title: String,
@JsonProperty("registrationDate") val registrationDate: String,
@JsonProperty("saleDate") val saleDate: String,
@JsonProperty("orderType") val orderType: String,
@JsonProperty("orderPrice") val orderPrice: Int,
@JsonProperty("numberOfPeople") val numberOfPeople: 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
)

View File

@@ -1,84 +0,0 @@
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,
val nickname: String,
val date: String,
val title: String,
// 유료방 입장 금액
val entranceFee: Int,
// 코인 사용 구분
val canUsage: CanUsage,
// 참여인원
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()
)
}
}

View File

@@ -1,19 +0,0 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateLiveResponse(
@JsonProperty("email") val email: String,
@JsonProperty("nickname") val nickname: String,
@JsonProperty("date") val date: String,
@JsonProperty("title") val title: String,
@JsonProperty("entranceFee") val entranceFee: Int,
@JsonProperty("canUsageStr") val canUsageStr: String,
@JsonProperty("numberOfPeople") val numberOfPeople: Int,
@JsonProperty("totalAmount") val totalAmount: 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
)

View File

@@ -1,92 +0,0 @@
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(
// 등록 크리에이터 닉네임
val nickname: String,
// 콘텐츠 제목
val title: String,
// 콘텐츠 등록 날짜
val registrationDate: String,
// 대여/소장 구분
val orderType: OrderType,
// 판매 금액(캔)
val orderPrice: Int,
// 인원
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()
)
}
}
data class GetCumulativeSalesByContentResponse(
@JsonProperty("totalCount") val totalCount: Int,
@JsonProperty("items") val items: List<CumulativeSalesByContentItem>
)
data class CumulativeSalesByContentItem(
@JsonProperty("nickname") val nickname: String,
@JsonProperty("title") val title: String,
@JsonProperty("registrationDate") val registrationDate: String,
@JsonProperty("orderType") val orderType: String,
@JsonProperty("orderPrice") val orderPrice: Int,
@JsonProperty("numberOfPeople") val numberOfPeople: 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
)

View File

@@ -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
)
}

View File

@@ -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
}
}

View File

@@ -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))
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
)

View File

@@ -1,7 +1,7 @@
package kr.co.vividnext.sodalive.admin.can
data class AdminCanChargeRequest(
val memberIds: List<Long>,
val memberId: Long,
val method: String,
val can: Int
)

View File

@@ -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))

View File

@@ -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>

View File

@@ -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
)
}

View File

@@ -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())
@@ -40,27 +35,22 @@ class AdminCanService(
@Transactional
fun charge(request: AdminCanChargeRequest) {
val member = memberRepository.findByIdOrNull(request.memberId)
?: throw SodaException("잘못된 회원번호 입니다.")
if (request.can <= 0) throw SodaException("1 캔 이상 입력하세요.")
if (request.method.isBlank()) throw SodaException("기록내용을 입력하세요.")
val ids = request.memberIds.distinct()
if (ids.isEmpty()) throw SodaException("회원번호를 입력하세요.")
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
val members = memberRepository.findAllById(ids).toList()
if (members.size != ids.size) throw SodaException("잘못된 회원번호 입니다.")
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
payment.method = request.method
charge.payment = payment
members.forEach { member ->
val charge = Charge(0, request.can, status = ChargeStatus.ADMIN)
charge.title = "${request.can.moneyFormat()}"
charge.member = member
chargeRepository.save(charge)
val payment = Payment(status = PaymentStatus.COMPLETE, paymentGateway = PaymentGateway.PG)
payment.method = request.method
charge.payment = payment
chargeRepository.save(charge)
member.pgRewardCan += charge.rewardCan
}
member.pgRewardCan += charge.rewardCan
}
}

View File

@@ -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))
}

View File

@@ -1,12 +1,10 @@
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
import kr.co.vividnext.sodalive.can.charge.ChargeStatus
import kr.co.vividnext.sodalive.can.charge.QCharge.charge
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.can.payment.QPayment.payment
import kr.co.vividnext.sodalive.member.QMember.member
@@ -15,7 +13,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 +25,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,47 +45,12 @@ 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
): List<GetChargeStatusDetailQueryDto> {
fun getChargeStatusDetail(startDate: LocalDateTime, endDate: LocalDateTime): List<GetChargeStatusDetailQueryDto> {
val formattedDate = Expressions.stringTemplate(
"DATE_FORMAT({0}, {1})",
Expressions.dateTimeTemplate(
@@ -100,20 +62,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 +70,7 @@ class AdminChargeStatusQueryRepository(private val queryFactory: JPAQueryFactory
member.nickname,
payment.method.coalesce(""),
payment.price,
currencyExpr,
can1.price,
formattedDate
)
)
@@ -130,7 +78,12 @@ 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))
)
.orderBy(formattedDate.desc())
.fetch()
}

View File

@@ -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.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
}
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,28 @@ class AdminChargeStatusService(val repository: AdminChargeStatusQueryRepository)
.withZoneSameInstant(ZoneId.of("UTC"))
.toLocalDateTime()
return repository.getChargeStatusDetail(startDate, endDate, paymentGateway, currency)
return repository.getChargeStatusDetail(startDate, endDate)
.asSequence()
.filter {
if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount > 0
} else {
it.pgChargeAmount > 0
}
}
.map {
GetChargeStatusDetailResponse(
memberId = it.memberId,
nickname = it.nickname,
method = it.method,
amount = it.amount,
locale = it.locale,
amount = if (paymentGateway == PaymentGateway.APPLE_IAP) {
it.appleChargeAmount.toInt()
} else {
it.pgChargeAmount
},
datetime = it.datetime
)
}
.toList()
}
}

View File

@@ -1,13 +1,12 @@
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 locale: String,
val appleChargeAmount: Double,
val pgChargeAmount: Int,
val datetime: String
)

View File

@@ -1,12 +1,9 @@
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 locale: String,
val amount: Int,
val datetime: String
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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, "배너 정렬 순서가 성공적으로 변경되었습니다.")
}
}

View File

@@ -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
)
)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
)
}

View File

@@ -1,448 +0,0 @@
package kr.co.vividnext.sodalive.admin.chat.character
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/admin/chat/character")
@PreAuthorize("hasRole('ADMIN')")
class AdminChatCharacterController(
private val service: ChatCharacterService,
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${weraser.api-key}")
private val apiKey: String,
@Value("\${weraser.api-url}")
private val apiUrl: String,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 활성화된 캐릭터 목록 조회 API
*
* @param page 페이지 번호 (0부터 시작, 기본값 0)
* @param size 페이지 크기 (기본값 20)
* @return 페이징된 캐릭터 목록
*/
@GetMapping("/list")
fun getCharacterList(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val response = adminService.getActiveChatCharacters(pageable, imageHost)
ApiResponse.ok(response)
}
/**
* 캐릭터 검색(관리자)
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
* - 페이징 지원: page, size 파라미터 사용
*/
@GetMapping("/search")
fun searchCharacters(
@RequestParam("searchTerm") searchTerm: String,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val resultPage = adminService.searchCharacters(searchTerm, pageable, imageHost)
val response = ChatCharacterSearchListPageResponse(
totalCount = resultPage.totalElements,
content = resultPage.content
)
ApiResponse.ok(response)
}
/**
* 캐릭터 상세 정보 조회 API
*
* @param characterId 캐릭터 ID
* @return 캐릭터 상세 정보
*/
@GetMapping("/{characterId}")
fun getCharacterDetail(
@PathVariable characterId: Long
) = run {
val response = adminService.getChatCharacterDetail(characterId, imageHost)
ApiResponse.ok(response)
}
@PostMapping("/register")
fun registerCharacter(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
// JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java)
// 외부 API 호출 전 DB에 동일한 이름이 있는지 조회
val existingCharacter = service.findByName(request.name)
if (existingCharacter != null) {
throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}")
}
// 1. 외부 API 호출
val characterUUID = callExternalApi(request)
// 2. ChatCharacter 저장
val chatCharacter = service.createChatCharacterWithDetails(
characterUUID = characterUUID,
name = request.name,
description = request.description,
systemPrompt = request.systemPrompt,
age = request.age?.toIntOrNull(),
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
characterType = request.characterType?.let {
runCatching { CharacterType.valueOf(it) }
.getOrDefault(CharacterType.Character)
} ?: CharacterType.Character,
tags = request.tags,
values = request.values,
hobbies = request.hobbies,
goals = request.goals,
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships
)
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
val imagePath = saveImage(
characterId = chatCharacter.id!!,
image = image
)
chatCharacter.imagePath = imagePath
service.saveChatCharacter(chatCharacter)
// 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
}
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = chatCharacter.id!!,
query = chatCharacter.description,
targetType = LanguageDetectTargetType.CHARACTER
)
)
}
ApiResponse.ok(null)
}
private fun callExternalApi(request: ChatCharacterRegisterRequest): String {
try {
val factory = SimpleClientHttpRequestFactory()
factory.setConnectTimeout(20000) // 20초
factory.setReadTimeout(20000) // 20초
val restTemplate = RestTemplate(factory)
val headers = HttpHeaders()
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
headers.contentType = MediaType.APPLICATION_JSON
// 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
val body = mutableMapOf<String, Any>()
body["name"] = request.name
body["systemPrompt"] = request.systemPrompt
body["description"] = request.description
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
if (request.tags.isNotEmpty()) body["tags"] = request.tags
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
if (request.values.isNotEmpty()) body["values"] = request.values
if (request.goals.isNotEmpty()) body["goals"] = request.goals
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
if (request.memories.isNotEmpty()) body["memories"] = request.memories
val httpEntity = HttpEntity(body, headers)
val response = restTemplate.exchange(
"$apiUrl/api/characters",
HttpMethod.POST,
httpEntity,
String::class.java
)
// 응답 파싱
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java)
// success가 false이면 throw
if (!apiResponse.success) {
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
)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CHARACTER
)
)
// 원작 연결: 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} 수정에 실패했습니다. 다시 시도해 주세요.")
}
}
}

View File

@@ -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))
}

View File

@@ -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
)

View File

@@ -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 }
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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>
)

View File

@@ -1,9 +0,0 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
/**
* 캐릭터 검색 결과 페이지 응답 DTO
*/
data class ChatCharacterSearchListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@@ -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>
)

View File

@@ -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}")
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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}")
}
}
}

View File

@@ -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>
)

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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,37 +18,18 @@ import java.time.LocalDateTime
interface AdminContentRepository : JpaRepository<AudioContent, Long>, AdminAudioContentQueryRepository
interface AdminAudioContentQueryRepository {
fun getAudioContentTotalCount(
searchWord: String = "",
status: ContentReleaseStatus = ContentReleaseStatus.OPEN
): Int
fun getAudioContentList(
status: ContentReleaseStatus = ContentReleaseStatus.OPEN,
offset: Long,
limit: Long,
searchWord: String = ""
): List<GetAdminContentListItem>
fun getAudioContentTotalCount(searchWord: String = ""): Int
fun getAudioContentList(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))
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
@@ -58,12 +38,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)
@@ -72,17 +46,10 @@ class AdminAudioContentQueryRepositoryImpl(
.size
}
override fun getAudioContentList(
status: ContentReleaseStatus,
offset: Long,
limit: Long,
searchWord: String
): List<GetAdminContentListItem> {
val now = LocalDateTime.now()
override fun getAudioContentList(offset: Long, limit: Long, searchWord: String): List<GetAdminContentListItem> {
var where = audioContent.duration.isNotNull
.and(audioContent.member.isNotNull)
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
.and(audioContent.isActive.isTrue)
if (searchWord.trim().length > 1) {
where = where.and(
@@ -91,12 +58,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(
@@ -105,19 +66,14 @@ class AdminAudioContentQueryRepositoryImpl(
audioContent.detail,
audioContentCuration.title,
audioContentCuration.id.nullif(0),
audioContent.coverImage.prepend("/").prepend(imageHost),
audioContent.coverImage,
audioContent.member!!.nickname,
audioContentTheme.theme,
audioContentTheme.id,
audioContent.price,
audioContent.limited,
audioContent.remaining,
audioContent.isAdult,
audioContent.duration,
audioContent.content,
audioContent.isCommentAvailable,
formattedDateExpression(audioContent.createdAt),
formattedDateExpression(audioContent.releaseDate, "%Y-%m-%d %H:%i")
formattedDateExpression(audioContent.createdAt)
)
)
.from(audioContent)
@@ -126,7 +82,7 @@ class AdminAudioContentQueryRepositoryImpl(
.where(where)
.offset(offset)
.limit(limit)
.orderBy(audioContent.releaseDate.desc())
.orderBy(audioContent.id.desc())
.fetch()
}
@@ -140,21 +96,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"

View File

@@ -1,11 +1,9 @@
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
@@ -14,20 +12,21 @@ import org.springframework.transaction.annotation.Transactional
@Service
class AdminContentService(
private val repository: AdminContentRepository,
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,
offset = pageable.offset,
limit = pageable.pageSize.toLong()
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
@@ -42,25 +41,26 @@ class AdminContentService(
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
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,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
searchWord = searchWord
)
val audioContentList = audioContentAndThemeList
.asSequence()
.map {
val tags = repository
.getHashTagList(audioContentId = it.audioContentId)
@@ -75,6 +75,11 @@ class AdminContentService(
)
it
}
.map {
it.coverImageUrl = "$coverImageHost/${it.coverImageUrl}"
it
}
.toList()
return GetAdminContentListResponse(totalCount, audioContentList)
}
@@ -85,13 +90,10 @@ class AdminContentService(
?: throw SodaException("없는 콘텐츠 입니다.")
if (request.isDefaultCoverImage) {
audioContent.coverImage = "`profile/default_profile.png`"
audioContent.coverImage = "profile/default_profile.png"
}
if (request.isActive != null) {
if (!request.isActive) {
audioContent.releaseDate = null
}
audioContent.isActive = request.isActive
}
@@ -115,14 +117,5 @@ class AdminContentService(
val curation = curationRepository.findByIdAndActive(id = request.curationId)
audioContent.curation = curation
}
if (request.themeId != null) {
val theme = themeRepository.findByIdAndActive(id = request.themeId)
audioContent.theme = theme
}
}
fun getContentMainTabList(): List<GetContentMainTabItem> {
return contentMainTabRepository.findAllByActiveIsTrue()
}
}

View File

@@ -16,16 +16,11 @@ data class GetAdminContentListItem @QueryProjection constructor(
var coverImageUrl: String,
val creatorNickname: String,
val theme: String,
val themeId: Long,
val price: Int,
val totalContentCount: Int?,
val remainingContentCount: Int?,
val isAdult: Boolean,
val remainingTime: String,
var contentUrl: String,
val isCommentAvailable: Boolean,
val date: String,
val releaseDate: String?
val date: String
) {
var tags: String = ""
}

View File

@@ -6,7 +6,6 @@ data class UpdateAdminContentRequest(
val title: String?,
val detail: String?,
val curationId: Long?,
val themeId: Long?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isCommentAvailable: Boolean?

View File

@@ -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())
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -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?

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class AddItemToCurationRequest(
val curationId: Long,
val itemIdList: List<Long>
)

View File

@@ -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())
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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?
)

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class RemoveItemInCurationRequest(
val curationId: Long,
val itemId: Long
)

View File

@@ -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
)

View File

@@ -1,6 +0,0 @@
package kr.co.vividnext.sodalive.admin.content.curation
data class UpdateCurationItemOrdersRequest(
val curationId: Long,
val itemIds: List<Long>
)

View File

@@ -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()
}
}

View File

@@ -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), "수정되었습니다.")
}

Some files were not shown because too many files have changed in this diff Show More