feat(admin): 관리자 페이지 캐릭터 리스트 API 구현

1. isActive가 true인 캐릭터만 조회하는 기능 구현
2. 페이징 처리 구현 (기본 20개 조회)
3. 필요한 데이터 포함 (id, 캐릭터명, 프로필 이미지, 설명, 성별, 나이, MBTI, 태그, 성격, 말투, 등록일, 수정일)
This commit is contained in:
Klaus 2025-08-07 11:57:53 +09:00
parent 45b6c8db96
commit 618f80fddc
4 changed files with 137 additions and 1 deletions

View File

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
@ -19,9 +20,11 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable import org.springframework.retry.annotation.Retryable
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping 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.RequestPart
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
@ -32,6 +35,7 @@ import org.springframework.web.multipart.MultipartFile
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
class AdminChatCharacterController( class AdminChatCharacterController(
private val service: ChatCharacterService, private val service: ChatCharacterService,
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
@Value("\${weraser.api-key}") @Value("\${weraser.api-key}")
@ -41,8 +45,29 @@ class AdminChatCharacterController(
private val apiUrl: String, private val apiUrl: String,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) { ) {
/**
* 활성화된 캐릭터 목록 조회 API
*
* @param page 페이지 번호 (0부터 시작, 기본값 0)
* @param size 페이지 크기 (기본값 20)
* @return 페이징된 캐릭터 목록
*/
@GetMapping("/list")
fun getCharacterList(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageable = adminService.createDefaultPageRequest(page, size)
val response = adminService.getActiveChatCharacters(pageable, imageHost)
ApiResponse.ok(response)
}
@PostMapping("/register") @PostMapping("/register")
@Retryable( @Retryable(
value = [Exception::class], value = [Exception::class],

View File

@ -0,0 +1,62 @@
package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class ChatCharacterListResponse(
val id: Long,
val name: String,
val imageUrl: String?,
val description: String,
val gender: String?,
val age: Int?,
val mbti: String?,
val speechStyle: String?,
val speechPattern: String?,
val tags: List<String>,
val createdAt: String?,
val updatedAt: String?
) {
companion object {
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val seoulZoneId = ZoneId.of("Asia/Seoul")
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse {
val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${chatCharacter.imagePath}"
} else {
chatCharacter.imagePath
}
// UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅
val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC"))
?.withZoneSameInstant(seoulZoneId)
?.format(formatter)
val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC"))
?.withZoneSameInstant(seoulZoneId)
?.format(formatter)
return ChatCharacterListResponse(
id = chatCharacter.id!!,
name = chatCharacter.name,
imageUrl = fullImagePath,
description = chatCharacter.description,
gender = chatCharacter.gender,
age = chatCharacter.age,
mbti = chatCharacter.mbti,
speechStyle = chatCharacter.speechStyle,
speechPattern = chatCharacter.speechPattern,
tags = chatCharacter.tagMappings.map { it.tag.tag },
createdAt = createdAtStr,
updatedAt = updatedAtStr
)
}
}
}
data class ChatCharacterListPageResponse(
val totalCount: Long,
val content: List<ChatCharacterListResponse>
)

View File

@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.admin.chat.character.service
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class AdminChatCharacterService(
private val chatCharacterRepository: ChatCharacterRepository
) {
/**
* 활성화된 캐릭터 목록을 페이징하여 조회
*
* @param pageable 페이징 정보
* @return 페이징된 캐릭터 목록
*/
@Transactional(readOnly = true)
fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse {
// isActive가 true인 캐릭터만 조회
val page = chatCharacterRepository.findByIsActiveTrue(pageable)
// 페이지 정보 생성
val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) }
return ChatCharacterListPageResponse(
totalCount = page.totalElements,
content = content
)
}
/**
* 기본 페이지 요청 생성
*
* @param page 페이지 번호 (0부터 시작)
* @param size 페이지 크기
* @return 페이지 요청 객체
*/
fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest {
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))
}
}

View File

@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.chat.character.repository package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@ -8,4 +10,5 @@ import org.springframework.stereotype.Repository
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByCharacterUUID(characterUUID: String): ChatCharacter?
fun findByName(name: String): ChatCharacter? fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
} }