feat(ai-character): 시리즈 관리자 API를 추가한다

This commit is contained in:
2026-07-30 00:31:52 +09:00
parent 7b652400b7
commit 79b4436812
13 changed files with 3497 additions and 1 deletions

View File

@@ -7,8 +7,19 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
import kr.co.vividnext.sodalive.creator.admin.content.series.content.QSearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import javax.persistence.LockModeType
interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository
interface CreatorAdminContentSeriesRepository : JpaRepository<Series, Long>, CreatorAdminContentSeriesQueryRepository {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Series s where s.member.id = :creatorId and s.id in :ids and s.isActive = true order by s.id asc")
fun findActiveByCreatorIdAndIdInForUpdate(
@Param("creatorId") creatorId: Long,
@Param("ids") ids: List<Long>
): List<Series>
}
interface CreatorAdminContentSeriesQueryRepository {
fun findByIdAndCreatorId(id: Long, creatorId: Long): Series?

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
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.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/{characterId:[0-9]+}/series")
class AiCharacterAdminSeriesController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminSeriesListResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@GetMapping("/{seriesId:[0-9]+}")
fun detail(
@PathVariable characterId: Long,
@PathVariable seriesId: Long
): ApiResponse<AiCharacterAdminSeriesDetailResponse> {
return ApiResponse.ok(facade.detail(characterId, seriesId))
}
@GetMapping("/{seriesId:[0-9]+}/contents")
fun contents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = ApiResponse.ok(facade.contents(characterId, seriesId, page, size))
@GetMapping("/{seriesId:[0-9]+}/contents/search")
fun searchContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestParam("search_word") searchWord: String
) = ApiResponse.ok(facade.searchContents(characterId, seriesId, searchWord))
@PostMapping("/{seriesId:[0-9]+}/contents", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun addContents(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.addContents(characterId, seriesId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{seriesId:[0-9]+}/contents/{contentId:[0-9]+}")
fun removeContent(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@PathVariable contentId: Long
): ApiResponse<Nothing> {
facade.removeContent(characterId, seriesId, contentId)
return ApiResponse.ok(null)
}
@PutMapping("/orders", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateOrders(
@PathVariable characterId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateOrders(characterId, request)
return ApiResponse.ok(null)
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.create(characterId, image, request)
return ApiResponse.ok(null)
}
@PutMapping("/{seriesId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@PathVariable seriesId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, seriesId, image, request)
return ApiResponse.ok(null)
}
private fun requireAllowedMultipartParts(multipartRequest: MultipartHttpServletRequest) {
if (multipartRequest.fileMap.keys.any { it !in SERIES_MULTIPART_PARTS } ||
multipartRequest.parts.any { it.name !in SERIES_MULTIPART_PARTS }
) {
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 SERIES_MULTIPART_PARTS = setOf("image", "request")
}
}

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.creator.admin.content.series.CreateSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
typealias AiCharacterAdminSeriesListResponse = GetCreatorAdminContentSeriesListResponse
typealias AiCharacterAdminSeriesListItem = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesDetailResponse = GetCreatorAdminContentSeriesListItem
typealias AiCharacterAdminSeriesCreateRequest = CreateSeriesRequest
data class AiCharacterAdminSeriesUpdateRequest(
val title: String? = null,
val introduction: String? = null,
val publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>? = null,
val genreId: Long? = null,
val isAdult: Boolean? = null,
val state: SeriesState? = null,
val isActive: Boolean? = null,
val writer: String? = null,
val studio: String? = null
)
data class AiCharacterAdminSeriesContentAddRequest(
val contentIdList: List<Long>?
)

View File

@@ -0,0 +1,220 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreService
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesService
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesContentResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.GetCreatorAdminContentSeriesListItem
import kr.co.vividnext.sodalive.creator.admin.content.series.ModifySeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.UpdateOrdersRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveContentToTheSeriesRequest
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
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.beans.factory.annotation.Value
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
@Service
class AiCharacterAdminSeriesFacade(
private val objectMapper: ObjectMapper,
private val targetResolver: AiCharacterAdminTargetResolver,
private val repository: AiCharacterAdminSeriesRepository,
private val legacyService: CreatorAdminContentSeriesService,
private val genreService: AdminContentSeriesGenreService,
@Value("\${cloud.aws.cloud-front.host}") private val coverImageHost: String
) {
@Transactional(readOnly = true)
fun genres(): List<GetSeriesGenreListResponse> {
return genreService.getSeriesGenreList()
}
@Transactional(readOnly = true)
fun list(characterId: Long, page: Int, size: Int): AiCharacterAdminSeriesListResponse {
val target = resolveActiveTarget(characterId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesList(
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long, seriesId: Long): AiCharacterAdminSeriesDetailResponse {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId)
?: throw invalidRequest()
return GetCreatorAdminContentSeriesListItem(
seriesId = series.id!!,
title = series.title,
introduction = series.introduction,
coverImageUrl = "$coverImageHost/${series.coverImage!!}",
publishedDaysOfWeek = series.publishedDaysOfWeek.toList(),
genreId = series.genre!!.id!!,
isAdult = series.isAdult,
state = series.state,
isActive = series.isActive,
writer = series.writer,
studio = series.studio
)
}
@Transactional(readOnly = true)
fun contents(
characterId: Long,
seriesId: Long,
page: Int,
size: Int
): GetCreatorAdminContentSeriesContentResponse {
val target = resolveOwnedActiveSeries(characterId, seriesId)
if (page < 0 || size < 1) throw invalidRequest()
val pageable = PageRequest.of(page, size)
return legacyService.getSeriesContent(
seriesId = seriesId,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
creatorId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional(readOnly = true)
fun searchContents(characterId: Long, seriesId: Long, searchWord: String): List<SearchContentNotInSeriesResponse> {
val target = resolveOwnedActiveSeries(characterId, seriesId)
return legacyService.searchContentNotInSeries(
seriesId = seriesId,
searchWord = searchWord,
memberId = target.creatorMember.id ?: throw invalidRequest()
)
}
@Transactional
fun addContents(characterId: Long, seriesId: Long, requestString: String) {
val request = readRequest(requestString, AiCharacterAdminSeriesContentAddRequest::class.java)
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val contentIds = request.contentIdList ?: throw invalidRequest()
if (contentIds.isEmpty()) {
legacyService.addingContentToTheSeries(
AddingContentToTheSeriesRequest(seriesId, contentIds),
creatorMemberId
)
return
}
if (contentIds.size != contentIds.toSet().size) throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
val linkedContentIds = series.contentList.mapNotNull { it.content?.id }.toSet()
contentIds.forEach { contentId ->
if (contentId in linkedContentIds ||
repository.findEligibleContentByIdAndCreatorMemberId(contentId, creatorMemberId) == null
) {
throw invalidRequest()
}
}
legacyService.addingContentToTheSeries(AddingContentToTheSeriesRequest(seriesId, contentIds), creatorMemberId)
}
@Transactional
fun removeContent(characterId: Long, seriesId: Long, contentId: Long?) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val resolvedContentId = contentId ?: throw invalidRequest()
val series = repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
if (series.contentList.none { it.content?.id == resolvedContentId && it.content?.member?.id == creatorMemberId }) {
throw invalidRequest()
}
legacyService.removeContentInTheSeries(RemoveContentToTheSeriesRequest(seriesId, resolvedContentId), creatorMemberId)
}
@Transactional
fun updateOrders(characterId: Long, requestString: String) {
val request = readRequest(requestString, UpdateOrdersRequest::class.java)
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val ids = request.ids
if (ids.isEmpty() || ids.size != ids.toSet().size) throw invalidRequest()
val lockedIds = repository.findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId, ids).map { it.id }.toSet()
if (lockedIds != ids.toSet()) throw invalidRequest()
legacyService.updateSeriesOrders(ids)
}
@Transactional
fun create(characterId: Long, image: MultipartFile, requestString: String) {
val target = resolveActiveTarget(characterId)
if (image.isEmpty) throw invalidRequest()
val request = readRequest(requestString, AiCharacterAdminSeriesCreateRequest::class.java)
rejectMissingActiveGenre(request.genreId)
legacyService.createSeries(image, requestString, target.creatorMember)
}
@Transactional
fun update(characterId: Long, seriesId: Long, image: MultipartFile?, requestString: String) {
val target = resolveOwnedActiveSeries(characterId, seriesId)
val normalizedImage = image?.takeUnless { it.isEmpty }
val request = readRequest(requestString, AiCharacterAdminSeriesUpdateRequest::class.java)
request.genreId?.let(::rejectMissingActiveGenre)
legacyService.modifySeries(
coverImage = normalizedImage,
requestString = objectMapper.writeValueAsString(
ModifySeriesRequest(
seriesId = seriesId,
title = request.title,
introduction = request.introduction,
publishedDaysOfWeek = request.publishedDaysOfWeek,
genreId = request.genreId,
isAdult = request.isAdult,
state = request.state,
isActive = request.isActive,
writer = request.writer,
studio = request.studio
)
),
member = target.creatorMember
)
}
private fun resolveOwnedActiveSeries(characterId: Long, seriesId: Long): AiCharacterAdminTarget {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(seriesId, creatorMemberId) ?: throw invalidRequest()
return target
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readerFor(requestClass)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.with(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun rejectMissingActiveGenre(genreId: Long) {
if (genreId <= 0 || !repository.existsActiveGenre(genreId)) throw invalidRequest()
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}

View File

@@ -0,0 +1,18 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.admin.content.series.genre.GetSeriesGenreListResponse
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminSeriesReferenceController(
private val facade: AiCharacterAdminSeriesFacade
) {
@GetMapping("/series-genres")
fun genres(): ApiResponse<List<GetSeriesGenreListResponse>> {
return ApiResponse.ok(facade.genres())
}
}

View File

@@ -0,0 +1,32 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminSeriesRepository(
private val repository: CreatorAdminContentSeriesRepository,
private val genreRepository: CreatorAdminContentSeriesGenreRepository,
private val audioContentRepository: AudioContentRepository
) {
fun findActiveByIdAndCreatorMemberId(seriesId: Long, creatorMemberId: Long): Series? {
return repository.findByIdAndCreatorId(seriesId, creatorMemberId)?.takeIf { it.isActive }
}
fun findActiveByCreatorMemberIdAndIdsForUpdate(creatorMemberId: Long, ids: List<Long>): List<Series> {
return repository.findActiveByCreatorIdAndIdInForUpdate(creatorMemberId, ids.sorted())
}
fun existsActiveGenre(genreId: Long): Boolean {
return genreRepository.findById(genreId).orElse(null)?.isActive == true
}
fun findEligibleContentByIdAndCreatorMemberId(contentId: Long, creatorMemberId: Long): AudioContent? {
return audioContentRepository.findByIdAndCreatorId(contentId, creatorMemberId)
?.takeIf { it.duration != null && (it.isActive || it.releaseDate != null) }
}
}