feat(ai-character): 오디오 콘텐츠 관리자 계약을 보강한다
This commit is contained in:
@@ -220,9 +220,6 @@ class AudioContentService(
|
||||
// request 내용 파싱
|
||||
val request = objectMapper.readValue(requestString, CreateAudioContentRequest::class.java)
|
||||
|
||||
// 미리듣기 시간 체크
|
||||
validatePreviewTime(request.previewStartTime, request.previewEndTime)
|
||||
|
||||
val releaseDate = if (request.releaseDate != null) {
|
||||
request.releaseDate.convertLocalDateTime("yyyy-MM-dd HH:mm")
|
||||
.atZone(ZoneId.of(request.timezone))
|
||||
@@ -232,11 +229,26 @@ class AudioContentService(
|
||||
LocalDateTime.now()
|
||||
}
|
||||
|
||||
return createAudioContent(contentFile, coverImage, request, releaseDate, member)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createAudioContent(
|
||||
contentFile: MultipartFile?,
|
||||
coverImage: MultipartFile?,
|
||||
request: CreateAudioContentRequest,
|
||||
releaseDate: LocalDateTime,
|
||||
member: Member
|
||||
): CreateAudioContentResponse {
|
||||
if (coverImage == null) throw SodaException(messageKey = "content.error.cover_image_required")
|
||||
|
||||
// contentFile 체크
|
||||
if (contentFile == null) {
|
||||
throw SodaException(messageKey = "content.error.content_required")
|
||||
}
|
||||
|
||||
validatePreviewTime(request.previewStartTime, request.previewEndTime)
|
||||
|
||||
// 테마 체크
|
||||
val theme = themeQueryRepository.findThemeByIdAndActive(id = request.themeId)
|
||||
?: throw SodaException(messageKey = "content.error.invalid_theme")
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.util.MultiValueMap
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException
|
||||
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
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/admin/ai-characters")
|
||||
@@ -25,30 +32,33 @@ class AiCharacterAdminAudioContentController(
|
||||
@GetMapping("/{characterId:[0-9]+}/audio-contents")
|
||||
fun list(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(required = false) search: String?,
|
||||
@RequestParam(required = false) status: String?,
|
||||
@RequestParam(value = "search_word", required = false) searchWord: String?,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminAudioContentListResponse> {
|
||||
return ApiResponse.ok(facade.list(characterId, search, status, page, size))
|
||||
return ApiResponse.ok(facade.list(characterId, searchWord, page, size))
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}")
|
||||
fun detail(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long
|
||||
@PathVariable contentId: Long,
|
||||
@RequestParam queryParameters: MultiValueMap<String, String>
|
||||
): ApiResponse<AiCharacterAdminAudioContentResponse> {
|
||||
return ApiResponse.ok(facade.detail(characterId, contentId))
|
||||
return ApiResponse.ok(facade.detail(characterId, contentId, queryParameters.keys))
|
||||
}
|
||||
|
||||
@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))
|
||||
@RequestPart("contentFile") contentFile: MultipartFile,
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<AiCharacterAdminAudioContentCreateResponse> {
|
||||
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
return ApiResponse.ok(facade.create(characterId, coverImage, contentFile, request))
|
||||
}
|
||||
|
||||
@PutMapping(
|
||||
@@ -59,9 +69,96 @@ class AiCharacterAdminAudioContentController(
|
||||
@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))
|
||||
@RequestPart("request") request: String,
|
||||
multipartRequest: MultipartHttpServletRequest
|
||||
): ApiResponse<Nothing> {
|
||||
requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
|
||||
requireJsonRequestPart(multipartRequest)
|
||||
facade.update(characterId, contentId, coverImage, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
private fun requireAllowedMultipartParts(
|
||||
multipartRequest: MultipartHttpServletRequest,
|
||||
allowedParts: Set<String>
|
||||
) {
|
||||
if (multipartRequest.fileMap.keys.any { it !in allowedParts } ||
|
||||
multipartRequest.parts.any { it.name !in allowedParts }
|
||||
) {
|
||||
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireJsonRequestPart(multipartRequest: MultipartHttpServletRequest) {
|
||||
val contentType = multipartRequest.getMultipartHeaders("request")?.contentType
|
||||
?: multipartRequest.getPart("request")?.contentType?.let(MediaType::parseMediaType)
|
||||
if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
|
||||
throw HttpMediaTypeNotSupportedException(contentType, listOf(MediaType.APPLICATION_JSON))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CREATE_MULTIPART_PARTS = setOf("coverImage", "contentFile", "request")
|
||||
private val UPDATE_MULTIPART_PARTS = setOf("coverImage", "request")
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments")
|
||||
fun comments(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long,
|
||||
@RequestParam queryParameters: MultiValueMap<String, String>,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
|
||||
return ApiResponse.ok(facade.comments(characterId, contentId, queryParameters.keys, page, size))
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments",
|
||||
consumes = [MediaType.APPLICATION_JSON_VALUE]
|
||||
)
|
||||
fun createComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<Nothing> {
|
||||
facade.createComment(characterId, contentId, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@PutMapping(
|
||||
"/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}",
|
||||
consumes = [MediaType.APPLICATION_JSON_VALUE]
|
||||
)
|
||||
fun updateComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<Nothing> {
|
||||
facade.updateComment(characterId, contentId, commentId, request)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@DeleteMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}")
|
||||
fun deleteComment(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long,
|
||||
@PathVariable commentId: Long
|
||||
): ApiResponse<Nothing> {
|
||||
facade.deleteComment(characterId, contentId, commentId)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
|
||||
@GetMapping("/{characterId:[0-9]+}/audio-contents/{contentId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
|
||||
fun replies(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable contentId: Long,
|
||||
@PathVariable commentId: Long,
|
||||
@RequestParam queryParameters: MultiValueMap<String, String>,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<AiCharacterAdminAudioContentCommentListResponse> {
|
||||
return ApiResponse.ok(facade.replies(characterId, contentId, commentId, queryParameters.keys, page, size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +1,31 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||
|
||||
import kr.co.vividnext.sodalive.content.CreateAudioContentRequest
|
||||
import kr.co.vividnext.sodalive.content.CreateAudioContentResponse
|
||||
import kr.co.vividnext.sodalive.content.GetAudioContentDetailResponse
|
||||
import kr.co.vividnext.sodalive.content.PurchaseOption
|
||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
|
||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListItem
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.GetCreatorAdminContentListResponse
|
||||
|
||||
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
|
||||
)
|
||||
typealias AiCharacterAdminAudioContentListResponse = GetCreatorAdminContentListResponse
|
||||
typealias AiCharacterAdminAudioContentListItem = GetCreatorAdminContentListItem
|
||||
typealias AiCharacterAdminAudioContentResponse = GetAudioContentDetailResponse
|
||||
typealias AiCharacterAdminAudioContentThemeResponse = GetAudioContentThemeResponse
|
||||
typealias AiCharacterAdminAudioContentCreateResponse = CreateAudioContentResponse
|
||||
typealias AiCharacterAdminAudioContentCommentListResponse = GetAudioContentCommentListResponse
|
||||
|
||||
data class AiCharacterAdminAudioContentCreateRequest(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val detail: String,
|
||||
val tags: 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 releaseDate: String? = null,
|
||||
val themeId: Long = 0,
|
||||
val isAdult: Boolean = false,
|
||||
val isGeneratePreview: Boolean = false,
|
||||
val isOnlyRental: Boolean = false,
|
||||
val isPointAvailable: Boolean = false,
|
||||
@@ -128,24 +33,38 @@ data class AiCharacterAdminAudioContentCreateRequest(
|
||||
val isFullDetailVisible: Boolean = true,
|
||||
val previewStartTime: String? = null,
|
||||
val previewEndTime: String? = null,
|
||||
val languageCode: String? = null,
|
||||
val seriesIds: List<Long> = emptyList()
|
||||
)
|
||||
val languageCode: String? = null
|
||||
) {
|
||||
fun toLegacyRequest(): CreateAudioContentRequest {
|
||||
return CreateAudioContentRequest(
|
||||
title = title,
|
||||
detail = detail,
|
||||
tags = tags,
|
||||
price = price,
|
||||
purchaseOption = purchaseOption,
|
||||
limited = limited,
|
||||
timezone = "UTC",
|
||||
themeId = themeId,
|
||||
isAdult = isAdult,
|
||||
isGeneratePreview = isGeneratePreview,
|
||||
isOnlyRental = isOnlyRental,
|
||||
isPointAvailable = isPointAvailable,
|
||||
isCommentAvailable = isCommentAvailable,
|
||||
isFullDetailVisible = isFullDetailVisible,
|
||||
previewStartTime = previewStartTime,
|
||||
previewEndTime = previewEndTime,
|
||||
languageCode = languageCode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AiCharacterAdminAudioContentUpdateRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val detail: 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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
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.comment.AudioContentCommentService
|
||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListResponse
|
||||
import kr.co.vividnext.sodalive.content.comment.ModifyCommentRequest
|
||||
import kr.co.vividnext.sodalive.content.comment.RegisterCommentRequest
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.CreatorAdminContentService
|
||||
import kr.co.vividnext.sodalive.creator.admin.content.UpdateCreatorAdminContentRequest
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTarget
|
||||
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
|
||||
@@ -14,10 +21,10 @@ 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.DateTimeException
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Service
|
||||
class AiCharacterAdminAudioContentFacade(
|
||||
@@ -27,104 +34,148 @@ class AiCharacterAdminAudioContentFacade(
|
||||
private val themeQueryRepository: AudioContentThemeQueryRepository,
|
||||
private val mapper: AiCharacterAdminAudioContentMapper,
|
||||
private val audioContentService: AudioContentService,
|
||||
private val creatorAdminContentService: CreatorAdminContentService
|
||||
private val creatorAdminContentService: CreatorAdminContentService,
|
||||
private val audioContentCommentService: AudioContentCommentService
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun themes(): List<AiCharacterAdminAudioContentThemeResponse> {
|
||||
return themeQueryRepository.getActiveThemes().map {
|
||||
AiCharacterAdminAudioContentThemeResponse(
|
||||
themeId = it.id,
|
||||
themeName = it.theme,
|
||||
imageUrl = it.image
|
||||
)
|
||||
}
|
||||
return themeQueryRepository.getActiveThemes()
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(
|
||||
characterId: Long,
|
||||
search: String?,
|
||||
status: String?,
|
||||
searchWord: 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()
|
||||
)
|
||||
val pageable = PageRequest.of(normalizedPage, size.coerceAtLeast(MINIMUM_PAGE_SIZE))
|
||||
return if (searchWord == null) {
|
||||
creatorAdminContentService.getAudioContentList(pageable, target.creatorMember)
|
||||
} else {
|
||||
creatorAdminContentService.searchAudioContent(searchWord, target.creatorMember, pageable)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(characterId: Long, contentId: Long): AiCharacterAdminAudioContentResponse {
|
||||
fun detail(
|
||||
characterId: Long,
|
||||
contentId: Long,
|
||||
queryParameterNames: Set<String>
|
||||
): AiCharacterAdminAudioContentResponse {
|
||||
if (queryParameterNames.isNotEmpty()) throw invalidRequest()
|
||||
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())
|
||||
return mapper.toResponse(content)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun comments(
|
||||
characterId: Long,
|
||||
contentId: Long,
|
||||
queryParameterNames: Set<String>,
|
||||
page: Int,
|
||||
size: Int
|
||||
): GetAudioContentCommentListResponse {
|
||||
val target = resolveOwnedActiveContent(characterId, contentId)
|
||||
validateCommentQuery(queryParameterNames, page, size)
|
||||
return utcCommentDates(
|
||||
audioContentCommentService.getCommentList(
|
||||
audioContentId = contentId,
|
||||
memberId = target.creatorMember.id ?: throw invalidRequest(),
|
||||
timezone = UTC_TIMEZONE,
|
||||
pageable = PageRequest.of(page, size)
|
||||
),
|
||||
contentId
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createComment(characterId: Long, contentId: Long, requestString: String) {
|
||||
val target = resolveOwnedActiveContent(characterId, contentId)
|
||||
val request = readCommentCreateRequest(requestString, contentId)
|
||||
request.parentId?.let { parentId ->
|
||||
repository.findActiveRootCommentByIdAndContentId(parentId, contentId) ?: throw invalidRequest()
|
||||
}
|
||||
audioContentCommentService.registerComment(
|
||||
member = target.creatorMember,
|
||||
comment = request.comment,
|
||||
audioContentId = contentId,
|
||||
parentId = request.parentId,
|
||||
isSecret = request.isSecret,
|
||||
languageCode = request.languageCode
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateComment(characterId: Long, contentId: Long, commentId: Long, requestString: String) {
|
||||
val target = resolveOwnedActiveContent(characterId, contentId)
|
||||
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
|
||||
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
|
||||
|
||||
val request = readCommentUpdateRequest(requestString, commentId)
|
||||
audioContentCommentService.modifyComment(request, target.creatorMember)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun deleteComment(characterId: Long, contentId: Long, commentId: Long) {
|
||||
val target = resolveOwnedActiveContent(characterId, contentId)
|
||||
val comment = repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
|
||||
if (!comment.isActive) return
|
||||
|
||||
audioContentCommentService.modifyComment(
|
||||
ModifyCommentRequest(commentId = commentId, isActive = false),
|
||||
target.creatorMember
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun replies(
|
||||
characterId: Long,
|
||||
contentId: Long,
|
||||
commentId: Long,
|
||||
queryParameterNames: Set<String>,
|
||||
page: Int,
|
||||
size: Int
|
||||
): GetAudioContentCommentListResponse {
|
||||
val target = resolveOwnedActiveContent(characterId, contentId)
|
||||
validateCommentQuery(queryParameterNames, page, size)
|
||||
repository.findCommentByIdAndContentId(commentId, contentId) ?: throw invalidRequest()
|
||||
return utcCommentDates(
|
||||
audioContentCommentService.getCommentReplyList(
|
||||
commentId = commentId,
|
||||
memberId = target.creatorMember.id ?: throw invalidRequest(),
|
||||
timezone = UTC_TIMEZONE,
|
||||
pageable = PageRequest.of(page, size)
|
||||
),
|
||||
contentId
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(
|
||||
characterId: Long,
|
||||
coverImage: MultipartFile,
|
||||
audioFile: MultipartFile,
|
||||
contentFile: MultipartFile,
|
||||
requestString: String
|
||||
): AiCharacterAdminAudioContentResponse {
|
||||
): AiCharacterAdminAudioContentCreateResponse {
|
||||
val target = targetResolver.resolve(characterId)
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
if (coverImage.isEmpty || audioFile.isEmpty) throw invalidRequest()
|
||||
if (coverImage.isEmpty || contentFile.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,
|
||||
val releaseDate = parseUtcReleaseDate(request.releaseDate) ?: LocalDateTime.now(ZoneOffset.UTC)
|
||||
return audioContentService.createAudioContent(
|
||||
contentFile = contentFile,
|
||||
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
|
||||
)
|
||||
),
|
||||
request = request.toLegacyRequest(),
|
||||
releaseDate = releaseDate,
|
||||
member = target.creatorMember
|
||||
)
|
||||
val content = repository.findByIdAndCreatorMemberId(created.contentId, creatorMemberId) ?: throw invalidRequest()
|
||||
replaceSeriesIds(content, request.seriesIds, creatorMemberId)
|
||||
|
||||
return mapper.toResponse(content, creatorMemberId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -132,27 +183,21 @@ class AiCharacterAdminAudioContentFacade(
|
||||
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(
|
||||
UpdateCreatorAdminContentRequest(
|
||||
id = contentId,
|
||||
title = request.title,
|
||||
detail = request.description,
|
||||
detail = request.detail,
|
||||
tags = request.tags,
|
||||
price = request.price,
|
||||
isAdult = request.isAdult,
|
||||
@@ -163,60 +208,95 @@ class AiCharacterAdminAudioContentFacade(
|
||||
),
|
||||
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)
|
||||
objectMapper.readerFor(requestClass)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
|
||||
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
|
||||
.readValue(requestString)
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasField(requestString: String, fieldName: String): Boolean {
|
||||
private fun parseUtcReleaseDate(releaseDate: String?): LocalDateTime? {
|
||||
if (releaseDate == null) return null
|
||||
if (!releaseDate.endsWith("Z")) throw invalidRequest()
|
||||
return try {
|
||||
objectMapper.readTree(requestString).has(fieldName)
|
||||
} catch (_: JsonProcessingException) {
|
||||
LocalDateTime.ofInstant(Instant.parse(releaseDate), ZoneOffset.UTC)
|
||||
} catch (_: DateTimeException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLegacyReleaseDate(): String {
|
||||
return toUtcLocalDateTime().format(LEGACY_RELEASE_DATE_FORMATTER)
|
||||
private fun resolveOwnedActiveContent(characterId: Long, contentId: Long): AiCharacterAdminTarget {
|
||||
val target = targetResolver.resolve(characterId)
|
||||
if (!target.chatCharacter.isActive) throw invalidRequest()
|
||||
repository.findActiveByIdAndCreatorMemberId(
|
||||
contentId = contentId,
|
||||
creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
) ?: throw invalidRequest()
|
||||
return target
|
||||
}
|
||||
|
||||
private fun String.toUtcLocalDateTime(): LocalDateTime {
|
||||
private fun validateCommentQuery(queryParameterNames: Set<String>, page: Int, size: Int) {
|
||||
if (!COMMENT_QUERY_PARAMETERS.containsAll(queryParameterNames) || page < 0 || size < MINIMUM_PAGE_SIZE) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun utcCommentDates(
|
||||
response: GetAudioContentCommentListResponse,
|
||||
contentId: Long
|
||||
): GetAudioContentCommentListResponse {
|
||||
return response.copy(items = response.items.map { it.withUtcDate(contentId) })
|
||||
}
|
||||
|
||||
private fun GetAudioContentCommentListItem.withUtcDate(contentId: Long): GetAudioContentCommentListItem {
|
||||
val createdAt = repository.findCommentByIdAndContentId(id, contentId)?.createdAt ?: throw invalidRequest()
|
||||
return copy(date = createdAt.toUtcIso())
|
||||
}
|
||||
|
||||
private fun readCommentCreateRequest(requestString: String, contentId: Long): RegisterCommentRequest {
|
||||
val serializedRequest = injectCommentPathId(
|
||||
requestString = requestString,
|
||||
allowedFields = CREATE_COMMENT_FIELDS,
|
||||
idField = "contentId",
|
||||
id = contentId
|
||||
)
|
||||
return readRequest(serializedRequest, RegisterCommentRequest::class.java)
|
||||
}
|
||||
|
||||
private fun readCommentUpdateRequest(requestString: String, commentId: Long): ModifyCommentRequest {
|
||||
val serializedRequest = injectCommentPathId(
|
||||
requestString = requestString,
|
||||
allowedFields = UPDATE_COMMENT_FIELDS,
|
||||
idField = "commentId",
|
||||
id = commentId
|
||||
)
|
||||
val request = readRequest(serializedRequest, ModifyCommentRequest::class.java)
|
||||
if (request.comment == null) throw invalidRequest()
|
||||
return request
|
||||
}
|
||||
|
||||
private fun injectCommentPathId(
|
||||
requestString: String,
|
||||
allowedFields: Set<String>,
|
||||
idField: String,
|
||||
id: Long
|
||||
): String {
|
||||
return try {
|
||||
Instant.parse(this).atOffset(ZoneOffset.UTC).toLocalDateTime()
|
||||
} catch (_: RuntimeException) {
|
||||
val request = objectMapper.readTree(requestString) as? com.fasterxml.jackson.databind.node.ObjectNode
|
||||
?: throw invalidRequest()
|
||||
if (request.fieldNames().asSequence().any { it !in allowedFields } || !request.hasNonNull("comment")) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
request.put(idField, id)
|
||||
objectMapper.writeValueAsString(request)
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
@@ -226,21 +306,10 @@ class AiCharacterAdminAudioContentFacade(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MINIMUM_PAGE_SIZE = 20
|
||||
private const val MAXIMUM_PAGE_SIZE = 50
|
||||
private const val MINIMUM_PAGE_SIZE = 1
|
||||
private const val UTC_TIMEZONE = "UTC"
|
||||
private val LEGACY_RELEASE_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
private val COMMENT_QUERY_PARAMETERS = setOf("page", "size")
|
||||
private val CREATE_COMMENT_FIELDS = setOf("comment", "parentId", "isSecret", "languageCode")
|
||||
private val UPDATE_COMMENT_FIELDS = setOf("comment")
|
||||
}
|
||||
|
||||
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?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,58 +2,36 @@ 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.AudioContentCreator
|
||||
import kr.co.vividnext.sodalive.content.PurchaseOption
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
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()
|
||||
fun toResponse(content: AudioContent): AiCharacterAdminAudioContentResponse {
|
||||
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,
|
||||
contentUrl = signedUrl(content).orEmpty(),
|
||||
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,
|
||||
releaseDate = releaseDate(content),
|
||||
totalContentCount = content.limited,
|
||||
remainingContentCount = content.remaining,
|
||||
orderSequence = null,
|
||||
@@ -66,13 +44,14 @@ class AiCharacterAdminAudioContentMapper(
|
||||
remainingTime = null,
|
||||
creatorOtherContentList = emptyList(),
|
||||
sameThemeOtherContentList = emptyList(),
|
||||
isCommentAvailable = content.isCommentAvailable,
|
||||
isLike = false,
|
||||
likeCount = 0,
|
||||
commentList = emptyList(),
|
||||
commentCount = 0,
|
||||
isPin = false,
|
||||
isAvailablePin = false,
|
||||
creator = AiCharacterAdminAudioContentCreatorResponse(
|
||||
creator = AudioContentCreator(
|
||||
creatorId = owner.id ?: throw invalidRequest(),
|
||||
nickname = owner.nickname,
|
||||
profileImageUrl = if (owner.profileImage != null) {
|
||||
@@ -88,11 +67,7 @@ class AiCharacterAdminAudioContentMapper(
|
||||
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()
|
||||
translated = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,16 +78,10 @@ class AiCharacterAdminAudioContentMapper(
|
||||
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 releaseDate(content: AudioContent): String? {
|
||||
return content.releaseDate
|
||||
?.takeIf { it > LocalDateTime.now(ZoneOffset.UTC) }
|
||||
?.toUtcIso()
|
||||
}
|
||||
|
||||
private fun invalidRequest(): AiCharacterAdminApiException {
|
||||
|
||||
@@ -1,52 +1,17 @@
|
||||
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.content.comment.AudioContentComment
|
||||
import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
|
||||
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
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) {
|
||||
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)
|
||||
@@ -55,89 +20,37 @@ class AiCharacterAdminAudioContentRepository(
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
fun findSeriesIds(contentId: Long, creatorMemberId: Long): List<Long> {
|
||||
fun findActiveByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
|
||||
return queryFactory
|
||||
.select(series.id)
|
||||
.from(seriesContent)
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.innerJoin(seriesContent.content, audioContent)
|
||||
.selectFrom(audioContent)
|
||||
.innerJoin(audioContent.member, member)
|
||||
.where(
|
||||
audioContent.id.eq(contentId)
|
||||
.and(audioContent.member.id.eq(creatorMemberId))
|
||||
.and(series.member.id.eq(creatorMemberId))
|
||||
.and(series.isActive.isTrue)
|
||||
.and(member.id.eq(creatorMemberId))
|
||||
.and(audioContent.isActive.isTrue)
|
||||
)
|
||||
.fetch()
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
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> {
|
||||
fun findCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
|
||||
return queryFactory
|
||||
.selectFrom(seriesContent)
|
||||
.innerJoin(seriesContent.series, series)
|
||||
.selectFrom(audioContentComment)
|
||||
.where(
|
||||
seriesContent.content.id.eq(contentId)
|
||||
.and(series.member.id.eq(creatorMemberId))
|
||||
audioContentComment.id.eq(commentId)
|
||||
.and(audioContentComment.audioContent.id.eq(contentId))
|
||||
)
|
||||
.fetch()
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
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> {
|
||||
fun findActiveRootCommentByIdAndContentId(commentId: Long, contentId: Long): AudioContentComment? {
|
||||
return queryFactory
|
||||
.selectFrom(series)
|
||||
.selectFrom(audioContentComment)
|
||||
.where(
|
||||
series.id.`in`(seriesIds)
|
||||
.and(series.member.id.eq(creatorMemberId))
|
||||
.and(series.isActive.isTrue)
|
||||
audioContentComment.id.eq(commentId)
|
||||
.and(audioContentComment.audioContent.id.eq(contentId))
|
||||
.and(audioContentComment.parent.isNull)
|
||||
.and(audioContentComment.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
|
||||
.fetchOne()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user