feat(ai-character): 팬톡 관리자 API를 추가한다
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
|
||||
import org.springframework.http.MediaType
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/admin/ai-characters/{characterId:[0-9]+}/fan-talks")
|
||||
class AiCharacterAdminFanTalkController(
|
||||
private val facade: AiCharacterAdminFanTalkFacade
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable characterId: Long,
|
||||
@RequestParam(defaultValue = "0") page: Int,
|
||||
@RequestParam(defaultValue = "20") size: Int
|
||||
): ApiResponse<CreatorChannelFanTalkTabResponse> {
|
||||
return ApiResponse.ok(facade.list(characterId, page, size))
|
||||
}
|
||||
|
||||
@PostMapping("/{fanTalkId:[0-9]+}/replies", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun createReply(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable fanTalkId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<AiCharacterAdminFanTalkReplyResponse> {
|
||||
return ApiResponse.ok(facade.createReply(characterId, fanTalkId, request))
|
||||
}
|
||||
|
||||
@PutMapping("/{fanTalkId:[0-9]+}/replies/{replyId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||
fun updateReply(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable fanTalkId: Long,
|
||||
@PathVariable replyId: Long,
|
||||
@RequestBody request: String
|
||||
): ApiResponse<CreatorChannelFanTalkResponse> {
|
||||
return ApiResponse.ok(facade.updateReply(characterId, fanTalkId, replyId, request))
|
||||
}
|
||||
|
||||
@DeleteMapping("/{fanTalkId:[0-9]+}")
|
||||
fun delete(
|
||||
@PathVariable characterId: Long,
|
||||
@PathVariable fanTalkId: Long
|
||||
): ApiResponse<Unit> {
|
||||
facade.delete(characterId, fanTalkId)
|
||||
return ApiResponse.ok(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class AiCharacterAdminFanTalkRecord(
|
||||
val fanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImagePath: String?,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class AiCharacterAdminFanTalkReplyRecord(
|
||||
val fanTalkId: Long,
|
||||
val parentFanTalkId: Long,
|
||||
val writerId: Long,
|
||||
val writerNickname: String,
|
||||
val writerProfileImagePath: String?,
|
||||
val content: String,
|
||||
val createdAt: LocalDateTime
|
||||
)
|
||||
|
||||
data class AiCharacterAdminFanTalkReplyRequest(
|
||||
val content: String
|
||||
)
|
||||
|
||||
data class AiCharacterAdminFanTalkReplyUpdateRequest(
|
||||
val content: String? = null,
|
||||
val isActive: Boolean? = null
|
||||
)
|
||||
|
||||
data class AiCharacterAdminFanTalkReplyResponse(
|
||||
val fanTalkId: Long,
|
||||
val replyId: Long,
|
||||
val creatorMemberId: Long,
|
||||
val content: String,
|
||||
val createdAtUtc: String
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
|
||||
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 kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkReplyResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
|
||||
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class AiCharacterAdminFanTalkFacade(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val targetResolver: AiCharacterAdminTargetResolver,
|
||||
private val repository: AiCharacterAdminFanTalkRepository,
|
||||
private val creatorCheersRepository: CreatorCheersRepository,
|
||||
private val queryPolicy: CreatorChannelFanTalkQueryPolicy,
|
||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun list(characterId: Long, page: Int, size: Int): CreatorChannelFanTalkTabResponse {
|
||||
val normalizedPage = queryPolicy.createPage(page, size)
|
||||
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
val fetchedRoots = repository.findActiveRoots(
|
||||
creatorMemberId = creatorMemberId,
|
||||
offset = normalizedPage.page.toLong() * normalizedPage.size,
|
||||
limit = normalizedPage.size + 1
|
||||
)
|
||||
val roots = queryPolicy.limitItems(fetchedRoots, normalizedPage)
|
||||
val repliesByRootId = repository.findActiveCreatorReplies(
|
||||
creatorMemberId = creatorMemberId,
|
||||
parentFanTalkIds = roots.map { it.fanTalkId }
|
||||
).groupBy(
|
||||
keySelector = { it.parentFanTalkId },
|
||||
valueTransform = { it.toResponse() }
|
||||
)
|
||||
|
||||
return CreatorChannelFanTalkTabResponse(
|
||||
fanTalkCount = repository.countActiveRoots(creatorMemberId),
|
||||
fanTalks = roots.map { it.toResponse(repliesByRootId[it.fanTalkId].orEmpty()) },
|
||||
page = normalizedPage.page,
|
||||
size = normalizedPage.size,
|
||||
hasNext = queryPolicy.hasNext(fetchedRoots, normalizedPage)
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun createReply(
|
||||
characterId: Long,
|
||||
fanTalkId: Long,
|
||||
requestString: String
|
||||
): AiCharacterAdminFanTalkReplyResponse {
|
||||
val request = readRequest(requestString, AiCharacterAdminFanTalkReplyRequest::class.java)
|
||||
if (request.content.isBlank()) throw invalidRequest()
|
||||
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creator = target.creatorMember
|
||||
val creatorMemberId = creator.id ?: throw invalidRequest()
|
||||
val parent = repository.findActiveRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest()
|
||||
val savedReply = creatorCheersRepository.save(
|
||||
CreatorCheers(cheers = request.content, languageCode = null).apply {
|
||||
member = creator
|
||||
this.creator = creator
|
||||
this.parent = parent
|
||||
}
|
||||
)
|
||||
val replyId = savedReply.id ?: throw invalidRequest()
|
||||
val createdAtUtc = savedReply.createdAt?.toUtcIso() ?: throw invalidRequest()
|
||||
|
||||
applicationEventPublisher.publishEvent(
|
||||
LanguageDetectEvent(
|
||||
id = replyId,
|
||||
query = request.content,
|
||||
targetType = LanguageDetectTargetType.CREATOR_CHEERS
|
||||
)
|
||||
)
|
||||
|
||||
return AiCharacterAdminFanTalkReplyResponse(
|
||||
fanTalkId = fanTalkId,
|
||||
replyId = replyId,
|
||||
creatorMemberId = creatorMemberId,
|
||||
content = request.content,
|
||||
createdAtUtc = createdAtUtc
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun delete(characterId: Long, fanTalkId: Long) {
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
val fanRoot = repository.findFanRoot(creatorMemberId, fanTalkId) ?: throw invalidRequest()
|
||||
|
||||
fanRoot.isActive = false
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updateReply(
|
||||
characterId: Long,
|
||||
fanTalkId: Long,
|
||||
replyId: Long,
|
||||
requestString: String
|
||||
): CreatorChannelFanTalkResponse {
|
||||
val request = readRequest(requestString, AiCharacterAdminFanTalkReplyUpdateRequest::class.java)
|
||||
val target = resolveActiveTarget(characterId)
|
||||
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
|
||||
val reply = repository.findUpdatableReply(creatorMemberId, fanTalkId, replyId) ?: throw invalidRequest()
|
||||
|
||||
request.content?.let { reply.cheers = it }
|
||||
request.isActive?.let { reply.isActive = it }
|
||||
|
||||
return CreatorChannelFanTalkResponse.from(reply, cloudFrontHost)
|
||||
}
|
||||
|
||||
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
|
||||
val target = targetResolver.resolve(characterId)
|
||||
if (!target.chatCharacter.isActive) throw invalidRequest()
|
||||
return target
|
||||
}
|
||||
|
||||
private fun AiCharacterAdminFanTalkRecord.toResponse(
|
||||
creatorReplies: List<CreatorChannelFanTalkReplyResponse>
|
||||
): CreatorChannelFanTalkResponse {
|
||||
return CreatorChannelFanTalkResponse(
|
||||
fanTalkId = fanTalkId,
|
||||
writerId = writerId,
|
||||
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
|
||||
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||
content = content,
|
||||
createdAtUtc = createdAt.toUtcIso(),
|
||||
creatorReplies = creatorReplies
|
||||
)
|
||||
}
|
||||
|
||||
private fun AiCharacterAdminFanTalkReplyRecord.toResponse(): CreatorChannelFanTalkReplyResponse {
|
||||
return CreatorChannelFanTalkReplyResponse(
|
||||
fanTalkId = fanTalkId,
|
||||
writerId = writerId,
|
||||
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
|
||||
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
||||
content = content,
|
||||
createdAtUtc = createdAt.toUtcIso()
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
|
||||
|
||||
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
|
||||
return try {
|
||||
objectMapper.readerFor(requestClass)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.readValue(requestString)
|
||||
} catch (_: JsonProcessingException) {
|
||||
throw invalidRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidRequest(): AiCharacterAdminApiException {
|
||||
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk
|
||||
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers
|
||||
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
class AiCharacterAdminFanTalkRepository(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) {
|
||||
fun countActiveRoots(creatorMemberId: Long): Int {
|
||||
return queryFactory
|
||||
.select(creatorCheers.id.count())
|
||||
.from(creatorCheers)
|
||||
.where(
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.isActive.isTrue,
|
||||
creatorCheers.parent.isNull
|
||||
)
|
||||
.fetchOne()
|
||||
?.toInt()
|
||||
?: 0
|
||||
}
|
||||
|
||||
fun findActiveRoots(
|
||||
creatorMemberId: Long,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<AiCharacterAdminFanTalkRecord> {
|
||||
return queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
AiCharacterAdminFanTalkRecord::class.java,
|
||||
creatorCheers.id,
|
||||
creatorCheers.member.id,
|
||||
creatorCheers.member.nickname,
|
||||
creatorCheers.member.profileImage,
|
||||
creatorCheers.cheers,
|
||||
creatorCheers.createdAt
|
||||
)
|
||||
)
|
||||
.from(creatorCheers)
|
||||
.where(
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.isActive.isTrue,
|
||||
creatorCheers.parent.isNull
|
||||
)
|
||||
.orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun findActiveCreatorReplies(
|
||||
creatorMemberId: Long,
|
||||
parentFanTalkIds: List<Long>
|
||||
): List<AiCharacterAdminFanTalkReplyRecord> {
|
||||
if (parentFanTalkIds.isEmpty()) return emptyList()
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
AiCharacterAdminFanTalkReplyRecord::class.java,
|
||||
creatorCheers.id,
|
||||
creatorCheers.parent.id,
|
||||
creatorCheers.member.id,
|
||||
creatorCheers.member.nickname,
|
||||
creatorCheers.member.profileImage,
|
||||
creatorCheers.cheers,
|
||||
creatorCheers.createdAt
|
||||
)
|
||||
)
|
||||
.from(creatorCheers)
|
||||
.where(
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.member.id.eq(creatorMemberId),
|
||||
creatorCheers.isActive.isTrue,
|
||||
creatorCheers.parent.id.`in`(parentFanTalkIds)
|
||||
)
|
||||
.orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
fun findActiveRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCheers)
|
||||
.where(
|
||||
creatorCheers.id.eq(fanTalkId),
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.isActive.isTrue,
|
||||
creatorCheers.parent.isNull
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
fun findFanRoot(creatorMemberId: Long, fanTalkId: Long): CreatorCheers? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorCheers)
|
||||
.where(
|
||||
creatorCheers.id.eq(fanTalkId),
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.member.id.ne(creatorMemberId),
|
||||
creatorCheers.parent.isNull
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
|
||||
fun findUpdatableReply(creatorMemberId: Long, fanTalkId: Long, replyId: Long): CreatorCheers? {
|
||||
val root = QCreatorCheers("root")
|
||||
return queryFactory
|
||||
.selectFrom(creatorCheers)
|
||||
.join(creatorCheers.parent, root)
|
||||
.where(
|
||||
creatorCheers.id.eq(replyId),
|
||||
creatorCheers.creator.id.eq(creatorMemberId),
|
||||
creatorCheers.member.id.eq(creatorMemberId),
|
||||
root.id.eq(fanTalkId),
|
||||
root.creator.id.eq(creatorMemberId),
|
||||
root.isActive.isTrue,
|
||||
root.parent.isNull
|
||||
)
|
||||
.fetchOne()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user