feat(ai-character): 오디오 콘텐츠 관리자 API를 추가한다

This commit is contained in:
2026-07-28 01:33:05 +09:00
parent 90c0182ae1
commit ec4e4a5768
15 changed files with 3468 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.http.MediaType
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.multipart.MultipartFile
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminAudioContentController(
private val facade: AiCharacterAdminAudioContentFacade
) {
@GetMapping("/audio-content-themes")
fun themes(): ApiResponse<List<AiCharacterAdminAudioContentThemeResponse>> {
return ApiResponse.ok(facade.themes())
}
@GetMapping("/{characterId:[0-9]+}/audio-contents")
fun list(
@PathVariable characterId: Long,
@RequestParam(required = false) search: String?,
@RequestParam(required = false) status: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminAudioContentListResponse> {
return ApiResponse.ok(facade.list(characterId, search, status, page, size))
}
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}")
fun detail(
@PathVariable characterId: Long,
@PathVariable contentId: Long
): ApiResponse<AiCharacterAdminAudioContentResponse> {
return ApiResponse.ok(facade.detail(characterId, contentId))
}
@PostMapping("/{characterId:[0-9]+}/audio-contents", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart("coverImage") coverImage: MultipartFile,
@RequestPart("audioFile") audioFile: MultipartFile,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminAudioContentResponse> {
return ApiResponse.ok(facade.create(characterId, coverImage, audioFile, request))
}
@PutMapping(
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}",
consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]
)
fun update(
@PathVariable characterId: Long,
@PathVariable contentId: Long,
@RequestPart(value = "coverImage", required = false) coverImage: MultipartFile?,
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminAudioContentResponse> {
return ApiResponse.ok(facade.update(characterId, contentId, coverImage, audioFile, request))
}
}

View File

@@ -0,0 +1,151 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.content.order.OrderType
data class AiCharacterAdminAudioContentListResponse(
val totalCount: Long,
val items: List<AiCharacterAdminAudioContentListItem>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class AiCharacterAdminAudioContentListItem(
val contentId: Long,
val title: String,
val coverImageUrl: String,
val audioSignedUrl: String?,
val price: Int,
val isAdult: Boolean,
val isActive: Boolean,
val releaseDateUtc: String?,
val status: AiCharacterAdminAudioContentStatus
)
data class AiCharacterAdminAudioContentResponse(
val contentId: Long,
val title: String,
val description: String,
val detail: String,
val coverImageUrl: String,
val audioSignedUrl: String?,
val contentUrl: String?,
val languageCode: String?,
val themeStr: String,
val tag: String,
val price: Int,
val duration: String,
val isAdult: Boolean,
val isActive: Boolean,
val isPointAvailable: Boolean,
val isCommentAvailable: Boolean,
val releaseDateUtc: String?,
val releaseDate: String?,
val totalContentCount: Int?,
val remainingContentCount: Int?,
val orderSequence: Int?,
val isActivePreview: Boolean,
val isMosaic: Boolean,
val isOnlyRental: Boolean,
val existOrdered: Boolean,
val purchaseOption: PurchaseOption,
val orderType: OrderType?,
val remainingTime: String?,
val creatorOtherContentList: List<AiCharacterAdminOtherContentResponse>,
val sameThemeOtherContentList: List<AiCharacterAdminOtherContentResponse>,
val isLike: Boolean,
val likeCount: Int,
val commentList: List<AiCharacterAdminAudioContentCommentResponse>,
val commentCount: Int,
val isPin: Boolean,
val isAvailablePin: Boolean,
val creator: AiCharacterAdminAudioContentCreatorResponse,
val previousContent: AiCharacterAdminOtherContentResponse?,
val nextContent: AiCharacterAdminOtherContentResponse?,
val buyerList: List<AiCharacterAdminContentBuyerResponse>,
val isAvailableUsePoint: Boolean,
val translated: AiCharacterAdminTranslatedContentResponse?,
val status: AiCharacterAdminAudioContentStatus,
val seriesIds: List<Long>,
val createdAtUtc: String?,
val updatedAtUtc: String?
)
data class AiCharacterAdminOtherContentResponse(
val contentId: Long,
val title: String,
val coverUrl: String
)
data class AiCharacterAdminAudioContentCommentResponse(
val commentId: Long,
val nickname: String,
val content: String
)
data class AiCharacterAdminAudioContentCreatorResponse(
val creatorId: Long,
val nickname: String,
val profileImageUrl: String,
val isFollowing: Boolean,
val isFollow: Boolean,
val isNotify: Boolean
)
data class AiCharacterAdminContentBuyerResponse(
val nickname: String,
val profileImageUrl: String
)
data class AiCharacterAdminTranslatedContentResponse(
val title: String,
val detail: String,
val tags: String
)
data class AiCharacterAdminAudioContentThemeResponse(
val themeId: Long,
val themeName: String,
val imageUrl: String
)
data class AiCharacterAdminAudioContentCreateRequest(
val title: String,
val description: String,
val price: Int,
val isAdult: Boolean = false,
val isActive: Boolean = true,
val themeId: Long,
val tags: String = "",
val purchaseOption: PurchaseOption = PurchaseOption.BOTH,
val limited: Int? = null,
val releaseDateUtc: String? = null,
val isGeneratePreview: Boolean = false,
val isOnlyRental: Boolean = false,
val isPointAvailable: Boolean = false,
val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null,
val previewEndTime: String? = null,
val languageCode: String? = null,
val seriesIds: List<Long> = emptyList()
)
data class AiCharacterAdminAudioContentUpdateRequest(
val title: String? = null,
val description: String? = null,
val tags: String? = null,
val price: Int? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null,
val releaseDateUtc: String? = null,
val seriesIds: List<Long>? = null,
val isPointAvailable: Boolean? = null,
val isCommentAvailable: Boolean? = null
)
enum class AiCharacterAdminAudioContentStatus {
OPEN,
SCHEDULED
}

View File

@@ -0,0 +1,246 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@Service
class AiCharacterAdminAudioContentFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminAudioContentRepository,
private val themeQueryRepository: AudioContentThemeQueryRepository,
private val mapper: AiCharacterAdminAudioContentMapper,
private val audioContentService: AudioContentService,
private val creatorAdminContentService: CreatorAdminContentService
) {
@Transactional(readOnly = true)
fun themes(): List<AiCharacterAdminAudioContentThemeResponse> {
return themeQueryRepository.getActiveThemes().map {
AiCharacterAdminAudioContentThemeResponse(
themeId = it.id,
themeName = it.theme,
imageUrl = it.image
)
}
}
@Transactional(readOnly = true)
fun list(
characterId: Long,
search: String?,
status: String?,
page: Int,
size: Int
): AiCharacterAdminAudioContentListResponse {
val target = targetResolver.resolve(characterId)
val normalizedSearch = search?.trim().orEmpty()
if (normalizedSearch.length == 1) throw invalidRequest()
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE)
val contents = repository.findPage(
creatorMemberId = target.creatorMember.id ?: throw invalidRequest(),
search = normalizedSearch,
status = parseStatus(status),
pageable = PageRequest.of(normalizedPage, normalizedSize)
)
return AiCharacterAdminAudioContentListResponse(
totalCount = contents.totalElements,
items = contents.content.map(mapper::toListItem),
page = normalizedPage,
size = normalizedSize,
hasNext = contents.hasNext()
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse {
val target = targetResolver.resolve(characterId)
val content = repository.findByIdAndCreatorMemberId(
contentId = contentId,
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
) ?: throw invalidRequest()
return mapper.toResponse(content, target.creatorMember.id ?: throw invalidRequest())
}
@Transactional
fun create(
characterId: Long,
coverImage: MultipartFile,
audioFile: MultipartFile,
requestString: String
): AiCharacterAdminAudioContentResponse {
val target = targetResolver.resolve(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
if (coverImage.isEmpty || audioFile.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminAudioContentCreateRequest::class.java)
if (!request.isActive) throw invalidRequest()
validateSeriesIds(request.seriesIds, creatorMemberId)
val created = audioContentService.createAudioContent(
contentFile = audioFile,
coverImage = coverImage,
requestString = objectMapper.writeValueAsString(
CreateAudioContentRequest(
title = request.title,
detail = request.description,
tags = request.tags,
price = request.price,
purchaseOption = request.purchaseOption,
limited = request.limited,
releaseDate = request.releaseDateUtc?.toLegacyReleaseDate(),
timezone = UTC_TIMEZONE,
themeId = request.themeId,
isAdult = request.isAdult,
isGeneratePreview = request.isGeneratePreview,
isOnlyRental = request.isOnlyRental,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable,
isFullDetailVisible = request.isFullDetailVisible,
previewStartTime = request.previewStartTime,
previewEndTime = request.previewEndTime,
languageCode = request.languageCode
)
),
member = target.creatorMember
)
val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest()
replaceSeriesIds(content, request.seriesIds, creatorMemberId)
return mapper.toResponse(content, creatorMemberId)
}
@Transactional
fun update(
characterId: Long,
contentId: Long,
coverImage: MultipartFile?,
audioFile: MultipartFile?,
requestString: String
): AiCharacterAdminAudioContentResponse {
val target = targetResolver.resolve(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
if (audioFile != null) throw invalidRequest()
val normalizedCoverImage = coverImage?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminAudioContentUpdateRequest::class.java)
request.seriesIds?.let { validateSeriesIds(it, creatorMemberId) }
if (request.isActive == false && request.releaseDateUtc != null) throw invalidRequest()
val hasReleaseDateUtc = hasField(requestString, "releaseDateUtc")
val releaseDateUtc = if (hasReleaseDateUtc) request.releaseDateUtc?.toUtcLocalDateTime() else null
creatorAdminContentService.updateAudioContent(
coverImage = normalizedCoverImage,
requestString = objectMapper.writeValueAsString(
CreatorAdminAudioContentUpdatePayload(
id = contentId,
title = request.title,
detail = request.description,
tags = request.tags,
price = request.price,
isAdult = request.isAdult,
isActive = request.isActive,
isPointAvailable = request.isPointAvailable,
isCommentAvailable = request.isCommentAvailable
)
),
member = target.creatorMember
)
val updatedContent = repository.findByIdAndCreatorMemberId(contentId, creatorMemberId) ?: throw invalidRequest()
if (hasReleaseDateUtc) {
updatedContent.releaseDate = releaseDateUtc
if (releaseDateUtc?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) {
updatedContent.isActive = false
}
}
request.seriesIds?.let { replaceSeriesIds(updatedContent, it, creatorMemberId) }
return mapper.toResponse(updatedContent, creatorMemberId)
}
private fun replaceSeriesIds(content: AudioContent, seriesIds: List<Long>, creatorMemberId: Long) {
try {
repository.replaceSeriesIds(content, seriesIds, creatorMemberId)
} catch (_: IllegalArgumentException) {
throw invalidRequest()
}
}
private fun validateSeriesIds(seriesIds: List<Long>, creatorMemberId: Long) {
if (!repository.hasActiveSeriesIds(seriesIds, creatorMemberId)) throw invalidRequest()
}
private fun parseStatus(status: String?): AiCharacterAdminAudioContentStatus? {
return status?.let {
runCatching { AiCharacterAdminAudioContentStatus.valueOf(it) }.getOrElse { throw invalidRequest() }
}
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readValue(requestString, requestClass)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun hasField(requestString: String, fieldName: String): Boolean {
return try {
objectMapper.readTree(requestString).has(fieldName)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun String.toLegacyReleaseDate(): String {
return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER)
}
private fun String.toUtcLocalDateTime(): LocalDateTime {
return try {
Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime()
} catch (_: RuntimeException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
companion object {
private const val MINIMUM_PAGE_SIZE = 20
private const val MAXIMUM_PAGE_SIZE = 50
private const val UTC_TIMEZONE = "UTC"
private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
}
private data class CreatorAdminAudioContentUpdatePayload(
val id: Long,
val title: String?,
val detail: String?,
val tags: String?,
val price: Int?,
val isAdult: Boolean?,
val isActive: Boolean?,
val isPointAvailable: Boolean?,
val isCommentAvailable: Boolean?
)
}

View File

@@ -0,0 +1,121 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.PurchaseOption
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@Component
class AiCharacterAdminAudioContentMapper(
private val repository: AiCharacterAdminAudioContentRepository,
private val audioContentCloudFront: AudioContentCloudFront,
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
fun toListItem(content: AudioContent): AiCharacterAdminAudioContentListItem {
return AiCharacterAdminAudioContentListItem(
contentId = content.id ?: throw invalidRequest(),
title = content.title,
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
audioSignedUrl = signedUrl(content),
price = content.price,
isAdult = content.isAdult,
isActive = content.isActive,
releaseDateUtc = content.releaseDate?.toUtcIso(),
status = status(content)
)
}
fun toResponse(content: AudioContent, creatorMemberId: Long): AiCharacterAdminAudioContentResponse {
val releaseDateUtc = content.releaseDate?.toUtcIso()
val owner = content.member ?: throw invalidRequest()
val audioSignedUrl = signedUrl(content)
return AiCharacterAdminAudioContentResponse(
contentId = content.id ?: throw invalidRequest(),
title = content.title,
description = content.detail,
detail = content.detail,
coverImageUrl = "$imageHost/${content.coverImage ?: "profile/default-profile.png"}",
audioSignedUrl = audioSignedUrl,
contentUrl = audioSignedUrl,
languageCode = content.languageCode,
themeStr = content.theme?.theme ?: "",
tag = content.audioContentHashTags.filter { it.isActive }.mapNotNull { it.hashTag?.tag }.joinToString(" "),
price = content.price,
duration = content.duration ?: "",
isAdult = content.isAdult,
isActive = content.isActive,
isPointAvailable = content.isPointAvailable,
isCommentAvailable = content.isCommentAvailable,
releaseDateUtc = releaseDateUtc,
releaseDate = null,
totalContentCount = content.limited,
remainingContentCount = content.remaining,
orderSequence = null,
isActivePreview = content.isGeneratePreview,
isMosaic = false,
isOnlyRental = content.purchaseOption == PurchaseOption.RENT_ONLY || content.isOnlyRental,
existOrdered = false,
purchaseOption = if (content.isOnlyRental) PurchaseOption.RENT_ONLY else content.purchaseOption,
orderType = null,
remainingTime = null,
creatorOtherContentList = emptyList(),
sameThemeOtherContentList = emptyList(),
isLike = false,
likeCount = 0,
commentList = emptyList(),
commentCount = 0,
isPin = false,
isAvailablePin = false,
creator = AiCharacterAdminAudioContentCreatorResponse(
creatorId = owner.id ?: throw invalidRequest(),
nickname = owner.nickname,
profileImageUrl = if (owner.profileImage != null) {
"$imageHost/${owner.profileImage}"
} else {
"$imageHost/profile/default-profile.png"
},
isFollowing = false,
isFollow = false,
isNotify = false
),
previousContent = null,
nextContent = null,
buyerList = emptyList(),
isAvailableUsePoint = content.isPointAvailable,
translated = null,
status = status(content),
seriesIds = repository.findSeriesIds(content.id ?: throw invalidRequest(), creatorMemberId),
createdAtUtc = content.createdAt?.toUtcIso(),
updatedAtUtc = content.updatedAt?.toUtcIso()
)
}
private fun signedUrl(content: AudioContent): String? {
val resourcePath = content.content ?: return null
val duration = content.duration ?: return null
val expirationTime = 1000 * 60 * 60 * (duration.split(":")[0].toLong() + 2)
return audioContentCloudFront.generateSignedURL(resourcePath, expirationTime)
}
private fun status(content: AudioContent): AiCharacterAdminAudioContentStatus {
return if (content.releaseDate?.isAfter(LocalDateTime.now(ZoneOffset.UTC)) == true) {
AiCharacterAdminAudioContentStatus.SCHEDULED
} else {
AiCharacterAdminAudioContentStatus.OPEN
}
}
private fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,143 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@Repository
class AiCharacterAdminAudioContentRepository(
private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager
) {
fun findPage(
creatorMemberId: Long,
search: String,
status: AiCharacterAdminAudioContentStatus?,
pageable: Pageable
): Page<AudioContent> {
val where = findWhere(creatorMemberId, search, status)
val contents = queryFactory
.selectFrom(audioContent)
.innerJoin(audioContent.member, member)
.where(where)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(audioContent.releaseDate.desc())
.fetch()
val totalCount = queryFactory
.select(audioContent.count())
.from(audioContent)
.innerJoin(audioContent.member, member)
.where(where)
.fetchOne() ?: 0L
return PageImpl(contents, pageable, totalCount)
}
fun findByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return queryFactory
.selectFrom(audioContent)
.innerJoin(audioContent.member, member)
.where(audioContent.id.eq(contentId).and(member.id.eq(creatorMemberId)))
.fetchOne()
}
fun findSeriesIds(contentId: Long, creatorMemberId: Long): List<Long> {
return queryFactory
.select(series.id)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.innerJoin(seriesContent.content, audioContent)
.where(
audioContent.id.eq(contentId)
.and(audioContent.member.id.eq(creatorMemberId))
.and(series.member.id.eq(creatorMemberId))
.and(series.isActive.isTrue)
)
.fetch()
}
fun replaceSeriesIds(content: AudioContent, seriesIds: List<Long>, creatorMemberId: Long) {
val requestedIds = seriesIds.distinct()
val existingSeriesContents = findSeriesContents(content.id ?: throw IllegalArgumentException(), creatorMemberId)
val existingSeriesIds = existingSeriesContents.mapNotNull { it.series?.id }.toSet()
existingSeriesContents
.filter { it.series?.id !in requestedIds }
.forEach(entityManager::remove)
if (requestedIds.isEmpty()) return
val seriesList = findActiveSeriesByIds(requestedIds, creatorMemberId)
if (seriesList.size != requestedIds.size) throw IllegalArgumentException()
seriesList.forEach {
if (it.id in existingSeriesIds) return@forEach
val seriesContent = SeriesContent()
seriesContent.series = it
seriesContent.content = content
entityManager.persist(seriesContent)
}
}
private fun findSeriesContents(contentId: Long, creatorMemberId: Long): List<SeriesContent> {
return queryFactory
.selectFrom(seriesContent)
.innerJoin(seriesContent.series, series)
.where(
seriesContent.content.id.eq(contentId)
.and(series.member.id.eq(creatorMemberId))
)
.fetch()
}
fun hasActiveSeriesIds(seriesIds: List<Long>, creatorMemberId: Long): Boolean {
return seriesIds.isEmpty() || findActiveSeriesByIds(seriesIds, creatorMemberId).size == seriesIds.distinct().size
}
private fun findActiveSeriesByIds(seriesIds: List<Long>, creatorMemberId: Long): List<Series> {
return queryFactory
.selectFrom(series)
.where(
series.id.`in`(seriesIds)
.and(series.member.id.eq(creatorMemberId))
.and(series.isActive.isTrue)
)
.fetch()
}
private fun findWhere(
creatorMemberId: Long,
search: String,
status: AiCharacterAdminAudioContentStatus?
): BooleanExpression {
val now = LocalDateTime.now(ZoneOffset.UTC)
var where = audioContent.duration.isNotNull
.and(audioContent.member.id.eq(creatorMemberId))
.and(audioContent.isActive.isTrue.or(audioContent.releaseDate.isNotNull))
if (status == AiCharacterAdminAudioContentStatus.SCHEDULED) {
where = where.and(audioContent.releaseDate.after(now))
} else if (status == AiCharacterAdminAudioContentStatus.OPEN) {
where = where.and(audioContent.releaseDate.isNull.or(audioContent.releaseDate.loe(now)))
}
if (search.length > 1) {
where = where.and(audioContent.title.contains(search).or(member.nickname.contains(search)))
}
return where
}
}

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
import kr.co.vividnext.sodalive.common.SodaException
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.http.HttpHeaders
@@ -54,6 +55,7 @@ class AiCharacterAdminExceptionHandler(
private fun resolveError(exception: Exception): ResolvedError {
return when (exception) {
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
is SodaException -> ResolvedError(HttpStatus.BAD_REQUEST, exception.messageKey ?: "common.error.invalid_request")
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)