diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 806d665..7f5bf84 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -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.ChatCharacterUpdateRequest 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.chat.character.service.ChatCharacterService 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.Retryable 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.PutMapping 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.client.RestTemplate @@ -32,6 +35,7 @@ import org.springframework.web.multipart.MultipartFile @PreAuthorize("hasRole('ADMIN')") class AdminChatCharacterController( private val service: ChatCharacterService, + private val adminService: AdminChatCharacterService, private val s3Uploader: S3Uploader, @Value("\${weraser.api-key}") @@ -41,8 +45,29 @@ class AdminChatCharacterController( private val apiUrl: String, @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") @Retryable( value = [Exception::class], diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt new file mode 100644 index 0000000..bf0977c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt @@ -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, + 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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt new file mode 100644 index 0000000..d9cd176 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt @@ -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")) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index f9547dd..4d39bcb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.chat.character.repository 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.stereotype.Repository @@ -8,4 +10,5 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? + fun findByIsActiveTrue(pageable: Pageable): Page }