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

This commit is contained in:
2026-07-30 00:33:46 +09:00
parent c45ad98db8
commit 1bfb387e78
12 changed files with 2228 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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