feat(original-app): 원작 상세, 캐릭터 리스트
- 원작 상세에 캐릭터 20개 조회 - 지정 원작에 속한 활성 캐릭터 목록 조회 API 추가
This commit is contained in:
		| @@ -1,6 +1,7 @@ | ||||
| package kr.co.vividnext.sodalive.chat.original.controller | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.character.dto.Character | ||||
| import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkCharactersPageResponse | ||||
| import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse | ||||
| import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse | ||||
| import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse | ||||
| @@ -13,6 +14,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||
| import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | ||||
|  | ||||
| /** | ||||
| @@ -50,6 +52,7 @@ class OriginalWorkController( | ||||
|      * - 로그인 및 본인인증 필수 | ||||
|      * - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크 | ||||
|      * - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description] | ||||
|      * - 캐릭터는 페이징 적용: 첫 페이지 20개 | ||||
|      */ | ||||
|     @GetMapping("/{id}") | ||||
|     fun detail( | ||||
| @@ -60,7 +63,8 @@ class OriginalWorkController( | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|  | ||||
|         val ow = queryService.getOriginalWork(id) | ||||
|         val characters = queryService.getActiveCharacters(id).map { | ||||
|         val pageRes = queryService.getActiveCharactersPage(id, page = 0, size = 20) | ||||
|         val characters = pageRes.content.map { | ||||
|             val path = it.imagePath ?: "profile/default-profile.png" | ||||
|             Character( | ||||
|                 characterId = it.id!!, | ||||
| @@ -72,4 +76,37 @@ class OriginalWorkController( | ||||
|         val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) | ||||
|         ApiResponse.ok(response) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 지정 원작에 속한 활성 캐릭터 목록 조회 (페이징) | ||||
|      * - 로그인 및 본인인증 필수 | ||||
|      * - 기본 페이지 사이즈 20 | ||||
|      */ | ||||
|     @GetMapping("/{id}/characters") | ||||
|     fun listCharacters( | ||||
|         @PathVariable id: Long, | ||||
|         @RequestParam(defaultValue = "0") page: Int, | ||||
|         @RequestParam(defaultValue = "20") size: Int, | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|  | ||||
|         val pageRes = queryService.getActiveCharactersPage(id, page, size) | ||||
|         val content = pageRes.content.map { | ||||
|             val path = it.imagePath ?: "profile/default-profile.png" | ||||
|             Character( | ||||
|                 characterId = it.id!!, | ||||
|                 name = it.name, | ||||
|                 description = it.description, | ||||
|                 imageUrl = "$imageHost/$path" | ||||
|             ) | ||||
|         } | ||||
|         ApiResponse.ok( | ||||
|             OriginalWorkCharactersPageResponse( | ||||
|                 totalCount = pageRes.totalElements, | ||||
|                 content = content | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -75,3 +75,11 @@ data class OriginalWorkDetailResponse( | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * 앱용: 원작별 활성 캐릭터 페이징 응답 DTO | ||||
|  */ | ||||
| data class OriginalWorkCharactersPageResponse( | ||||
|     @JsonProperty("totalCount") val totalCount: Long, | ||||
|     @JsonProperty("content") val content: List<Character> | ||||
| ) | ||||
|   | ||||
| @@ -5,6 +5,9 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepositor | ||||
| import kr.co.vividnext.sodalive.chat.original.OriginalWork | ||||
| import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import org.springframework.data.domain.Page | ||||
| import org.springframework.data.domain.PageRequest | ||||
| import org.springframework.data.domain.Sort | ||||
| import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | ||||
|  | ||||
| @@ -36,10 +39,21 @@ class OriginalWorkQueryService( | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 지정 원작에 속한 활성 캐릭터 목록 조회 (최신순) | ||||
|      * 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) | ||||
|      */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun getActiveCharacters(originalWorkId: Long): List<ChatCharacter> { | ||||
|         return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) | ||||
|     fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page<ChatCharacter> { | ||||
|         // 원작 존재 및 소프트 삭제 여부 확인 | ||||
|         originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|  | ||||
|         val safePage = if (page < 0) 0 else page | ||||
|         val safeSize = when { | ||||
|             size <= 0 -> 20 | ||||
|             size > 50 -> 50 | ||||
|             else -> size | ||||
|         } | ||||
|         val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) | ||||
|         return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user