feat(ai-character): 커뮤니티 게시글 관리자 API를 추가한다

This commit is contained in:
2026-07-30 00:32:49 +09:00
parent 79b4436812
commit c45ad98db8
11 changed files with 2985 additions and 0 deletions

View File

@@ -0,0 +1,138 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
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]+}/community-posts")
class AiCharacterAdminCommunityPostController(
private val facade: AiCharacterAdminCommunityPostFacade
) {
@GetMapping
fun list(
@PathVariable characterId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostListResponse> {
return ApiResponse.ok(facade.list(characterId, page, size))
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@PathVariable characterId: Long,
@RequestPart(value = "audioFile", required = false) audioFile: MultipartFile?,
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest, CREATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.create(characterId, audioFile, postImage, request)
return ApiResponse.ok(null)
}
@PutMapping("/{postId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestPart(value = "postImage", required = false) postImage: MultipartFile?,
@RequestPart("request") request: String,
multipartRequest: MultipartHttpServletRequest
): ApiResponse<Nothing> {
requireAllowedMultipartParts(multipartRequest, UPDATE_MULTIPART_PARTS)
requireJsonRequestPart(multipartRequest)
facade.update(characterId, postId, postImage, 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("audioFile", "postImage", "request")
private val UPDATE_MULTIPART_PARTS = setOf("postImage", "request")
}
@GetMapping("/{postId:[0-9]+}/comments")
fun comments(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
return ApiResponse.ok(facade.comments(characterId, postId, page, size))
}
@PostMapping("/{postId:[0-9]+}/comments", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun createComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.createComment(characterId, postId, request)
return ApiResponse.ok(null)
}
@PutMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}", consumes = [MediaType.APPLICATION_JSON_VALUE])
fun updateComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long,
@RequestBody request: String
): ApiResponse<Nothing> {
facade.updateComment(characterId, postId, commentId, request)
return ApiResponse.ok(null)
}
@DeleteMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}")
fun deleteComment(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long
): ApiResponse<Nothing> {
facade.deleteComment(characterId, postId, commentId)
return ApiResponse.ok(null)
}
@GetMapping("/{postId:[0-9]+}/comments/{commentId:[0-9]+}/replies")
fun commentReplies(
@PathVariable characterId: Long,
@PathVariable postId: Long,
@PathVariable commentId: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminCommunityPostCommentListResponse> {
return ApiResponse.ok(facade.commentReplies(characterId, postId, commentId, page, size))
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.GetCommunityPostListResponse
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListResponse
typealias AiCharacterAdminCommunityPostDto = GetCommunityPostListResponse
typealias AiCharacterAdminCommunityPostCommentListResponse = GetCommunityPostCommentListResponse
data class AiCharacterAdminCommunityPostListResponse(
val totalCount: Long,
val page: Int,
val size: Int,
val hasNext: Boolean,
val items: List<AiCharacterAdminCommunityPostDto>
)
data class AiCharacterAdminCommunityPostUpdateRequest(
val content: String? = null,
val isCommentAvailable: Boolean? = null,
val isAdult: Boolean? = null,
val isActive: Boolean? = null,
val isFixed: Boolean? = null
)
data class AiCharacterAdminCommunityPostCommentCreateRequest(
val comment: String,
val parentId: Long? = null,
val isSecret: Boolean = false
)
data class AiCharacterAdminCommunityPostCommentUpdateRequest(
val comment: String
)

View File

@@ -0,0 +1,286 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreateCommunityPostRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.ModifyCommunityPostRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.UpdateCommunityPostFixedRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.GetCommunityPostCommentListItem
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.ModifyCommunityPostCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.extensions.getTimeAgoString
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
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
import java.time.ZoneId
@Service
class AiCharacterAdminCommunityPostFacade(
private val targetResolver: AiCharacterAdminTargetResolver,
private val legacyService: CreatorCommunityService,
private val objectMapper: ObjectMapper,
private val repository: AiCharacterAdminCommunityPostRepository,
private val memberRepository: MemberRepository,
private val likeRepository: CreatorCommunityLikeRepository,
private val commentRepository: CreatorCommunityCommentRepository,
private val audioContentCloudFront: AudioContentCloudFront,
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
@Transactional
fun create(characterId: Long, audioFile: MultipartFile?, postImage: MultipartFile?, request: String) {
val target = resolveActiveTarget(characterId)
readRequest(request, CreateCommunityPostRequest::class.java)
legacyService.createCommunityPost(audioFile, postImage, request, target.creatorMember)
}
@Transactional
fun update(characterId: Long, postId: Long, postImage: MultipartFile?, requestString: String) {
val target = resolveActiveTarget(characterId)
val creatorMember = target.creatorMember
val creatorMemberId = creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
rejectExplicitNull(requestString, "isFixed")
val request = readRequest(requestString, AiCharacterAdminCommunityPostUpdateRequest::class.java)
val modifyRequest = ModifyCommunityPostRequest(
creatorCommunityId = postId,
content = request.content,
isCommentAvailable = request.isCommentAvailable,
isAdult = request.isAdult,
isActive = request.isActive
)
if (request.isActive != false && request.isFixed != null) {
memberRepository.findByIdForUpdate(creatorMemberId) ?: throw invalidRequest()
legacyService.updateCommunityPostFixed(
UpdateCommunityPostFixedRequest(postId = postId, isFixed = request.isFixed),
creatorMember
)
}
legacyService.modifyCommunityPost(postImage, objectMapper.writeValueAsString(modifyRequest), creatorMember)
}
@Transactional(readOnly = true)
fun list(
characterId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostListResponse {
val target = resolveActiveTarget(characterId)
if (page < 0 || size < 1) throw invalidRequest()
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
val pageable = PageRequest.of(page, size)
val items = repository.findActiveByCreatorMemberId(creatorMemberId, pageable)
.map { post -> post.toResponse(target.creatorMember, creatorMemberId) }
val totalCount = repository.countActiveByCreatorMemberId(creatorMemberId)
return AiCharacterAdminCommunityPostListResponse(
totalCount = totalCount,
page = page,
size = size,
hasNext = (page + 1L) * size < totalCount,
items = items
)
}
@Transactional(readOnly = true)
fun comments(
characterId: Long,
postId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostCommentListResponse {
val target = resolveOwnedActivePost(characterId, postId)
validateListRequest(page, size)
val pageable = PageRequest.of(page, size)
return utcCommentDates(
legacyService.getCommunityPostCommentList(
postId = postId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = true
),
postId
)
}
@Transactional(readOnly = true)
fun commentReplies(
characterId: Long,
postId: Long,
commentId: Long,
page: Int,
size: Int
): AiCharacterAdminCommunityPostCommentListResponse {
val target = resolveOwnedActivePost(characterId, postId)
validateListRequest(page, size)
repository.findActiveRootCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
val pageable = PageRequest.of(page, size)
return utcCommentDates(
legacyService.getCommentReplyList(
commentId = commentId,
memberId = target.creatorMember.id ?: throw invalidRequest(),
timezone = UTC_TIMEZONE,
offset = pageable.offset,
limit = pageable.pageSize.toLong(),
isAdult = true
),
postId
)
}
@Transactional
fun createComment(characterId: Long, postId: Long, requestString: String) {
val target = resolveOwnedActivePost(characterId, postId)
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentCreateRequest::class.java)
request.parentId?.let { parentId ->
repository.findActiveRootCommentByIdAndPostId(parentId, postId) ?: throw invalidRequest()
}
legacyService.createCommunityPostComment(
member = target.creatorMember,
comment = request.comment,
postId = postId,
parentId = request.parentId,
isSecret = request.isSecret,
isAdult = true
)
}
@Transactional
fun updateComment(characterId: Long, postId: Long, commentId: Long, requestString: String) {
val target = resolveOwnedActivePost(characterId, postId)
val request = readRequest(requestString, AiCharacterAdminCommunityPostCommentUpdateRequest::class.java)
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
if (!comment.isActive || comment.member?.id != target.creatorMember.id) throw invalidRequest()
legacyService.modifyCommunityPostComment(
ModifyCommunityPostCommentRequest(commentId = commentId, comment = request.comment, isActive = null),
target.creatorMember
)
}
@Transactional
fun deleteComment(characterId: Long, postId: Long, commentId: Long) {
val target = resolveOwnedActivePost(characterId, postId)
val comment = repository.findCommentByIdAndPostId(commentId, postId) ?: throw invalidRequest()
if (!comment.isActive) return
legacyService.modifyCommunityPostComment(
ModifyCommunityPostCommentRequest(commentId = commentId, comment = null, isActive = false),
target.creatorMember
)
}
private fun CreatorCommunity.toResponse(member: Member, creatorMemberId: Long): AiCharacterAdminCommunityPostDto {
val postId = id ?: throw invalidRequest()
val createdAt = createdAt ?: throw invalidRequest()
val audioUrl = audioPath?.let {
audioContentCloudFront.generateSignedURL(it, AUDIO_URL_EXPIRATION_MILLIS)
}
val commentCount = if (isCommentAvailable) {
commentRepository.totalCountCommentByPostId(
postId = postId,
memberId = creatorMemberId,
isContentCreator = true
)
} else {
0
}
return AiCharacterAdminCommunityPostDto(
postId = postId,
creatorId = creatorMemberId,
creatorNickname = member.nickname,
creatorProfileUrl = "$imageHost/${member.profileImage ?: DEFAULT_PROFILE_IMAGE_PATH}",
imageUrl = imagePath?.let { "$imageHost/$it" },
audioUrl = audioUrl,
content = content,
price = price,
date = createdAt.getTimeAgoString(),
dateUtc = createdAt.atZone(ZoneId.of("UTC")).toInstant().toString(),
isCommentAvailable = isCommentAvailable,
isAdult = isAdult,
isFixed = isFixed,
isLike = false,
existOrdered = true,
likeCount = likeRepository.totalCountCommunityPostLikeByPostId(postId),
commentCount = commentCount,
firstComment = null
)
}
private fun resolveActiveTarget(characterId: Long): AiCharacterAdminTarget {
val target = targetResolver.resolve(characterId)
if (!target.chatCharacter.isActive) throw invalidRequest()
return target
}
private fun resolveOwnedActivePost(characterId: Long, postId: Long): AiCharacterAdminTarget {
val target = resolveActiveTarget(characterId)
val creatorMemberId = target.creatorMember.id ?: throw invalidRequest()
repository.findActiveByIdAndCreatorMemberId(postId, creatorMemberId) ?: throw invalidRequest()
return target
}
private fun validateListRequest(page: Int, size: Int) {
if (page < 0 || size < 1) throw invalidRequest()
}
private fun utcCommentDates(
response: AiCharacterAdminCommunityPostCommentListResponse,
postId: Long
): AiCharacterAdminCommunityPostCommentListResponse {
return response.copy(items = response.items.map { it.withUtcDate(postId) })
}
private fun GetCommunityPostCommentListItem.withUtcDate(postId: Long): GetCommunityPostCommentListItem {
val createdAt = repository.findCommentByIdAndPostId(id, postId)?.createdAt ?: throw invalidRequest()
return copy(date = createdAt.toUtcIso())
}
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)
.with(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES)
.readValue(requestString)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun rejectExplicitNull(requestString: String, fieldName: String) {
try {
if (objectMapper.readTree(requestString).path(fieldName).isNull) throw invalidRequest()
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
private companion object {
const val AUDIO_URL_EXPIRATION_MILLIS = 1_800_000L
const val DEFAULT_PROFILE_IMAGE_PATH = "profile/default_profile.png"
const val UTC_TIMEZONE = "UTC"
}
}

View File

@@ -0,0 +1,75 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
@Repository
class AiCharacterAdminCommunityPostRepository(
private val queryFactory: JPAQueryFactory
) {
fun findActiveByCreatorMemberId(creatorMemberId: Long, pageable: Pageable): List<CreatorCommunity> {
return queryFactory
.selectFrom(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorMemberId)
.and(creatorCommunity.isActive.isTrue)
)
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.orderBy(
creatorCommunity.isFixed.desc(),
creatorCommunity.fixedAt.desc().nullsLast(),
creatorCommunity.createdAt.desc()
)
.fetch()
}
fun countActiveByCreatorMemberId(creatorMemberId: Long): Long {
return queryFactory
.select(creatorCommunity.count())
.from(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorMemberId)
.and(creatorCommunity.isActive.isTrue)
)
.fetchOne() ?: 0L
}
fun findActiveByIdAndCreatorMemberId(postId: Long, creatorMemberId: Long): CreatorCommunity? {
return queryFactory
.selectFrom(creatorCommunity)
.where(
creatorCommunity.id.eq(postId)
.and(creatorCommunity.member.id.eq(creatorMemberId))
.and(creatorCommunity.isActive.isTrue)
)
.fetchOne()
}
fun findCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
return queryFactory
.selectFrom(creatorCommunityComment)
.where(
creatorCommunityComment.id.eq(commentId)
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
)
.fetchOne()
}
fun findActiveRootCommentByIdAndPostId(commentId: Long, postId: Long): CreatorCommunityComment? {
return queryFactory
.selectFrom(creatorCommunityComment)
.where(
creatorCommunityComment.id.eq(commentId)
.and(creatorCommunityComment.creatorCommunity.id.eq(postId))
.and(creatorCommunityComment.parent.isNull)
.and(creatorCommunityComment.isActive.isTrue)
)
.fetchOne()
}
}