From 47b2c1cb934675eac5eabbc0dfbd78c74aae254d Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 06:17:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(original):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다. --- .../original/service/AdminOriginalWorkService.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index 288ddf5..57dcce7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -145,11 +145,17 @@ class AdminOriginalWorkService( /** 단일 캐릭터를 지정 원작에 배정 */ @Transactional fun assignOneCharacter(originalWorkId: Long, characterId: Long) { - val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) - .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } val character = chatCharacterRepository.findById(characterId) .orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") } - character.originalWork = ow + + if (characterId == 0L) { + character.originalWork = null + } else { + val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) + .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + character.originalWork = ow + } + chatCharacterRepository.save(character) } } From adcaa0a5fd2fe3ff62e1599f9db25fa8ce71deea Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 06:43:40 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(original):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원작 ID가 0이 들어오면 캐릭터의 원작을 null로 처리한다. --- .../admin/chat/original/service/AdminOriginalWorkService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index 57dcce7..e855f91 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -148,7 +148,7 @@ class AdminOriginalWorkService( val character = chatCharacterRepository.findById(characterId) .orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") } - if (characterId == 0L) { + if (originalWorkId == 0L) { character.originalWork = null } else { val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) From 387f5388d97e5011da7680af06a2adee7de9a998 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 15:32:20 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(original-app):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EC=83=81=EC=84=B8,=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원작 상세에 캐릭터 20개 조회 - 지정 원작에 속한 활성 캐릭터 목록 조회 API 추가 --- .../controller/OriginalWorkController.kt | 39 ++++++++++++++++++- .../chat/original/dto/OriginalWorkAppDtos.kt | 8 ++++ .../service/OriginalWorkQueryService.kt | 20 ++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 4b7dd4b..e8b6301 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -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 + ) + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt index 9c38622..7520eca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -75,3 +75,11 @@ data class OriginalWorkDetailResponse( } } } + +/** + * 앱용: 원작별 활성 캐릭터 페이징 응답 DTO + */ +data class OriginalWorkCharactersPageResponse( + @JsonProperty("totalCount") val totalCount: Long, + @JsonProperty("content") val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt index 1377dab..01dc3e6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -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 { - return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) + fun getActiveCharactersPage(originalWorkId: Long, page: Int = 0, size: Int = 20): Page { + // 원작 존재 및 소프트 삭제 여부 확인 + 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) } } From edeecad2ce238261ebe556bae441a7be8aa28d62 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 16:00:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(original-app):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이징 추가 --- .../chat/original/OriginalWorkRepository.kt | 15 ++++++++++++--- .../original/controller/OriginalWorkController.kt | 11 +++++++---- .../original/service/OriginalWorkQueryService.kt | 15 ++++++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt index 97856f5..4e40d33 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -33,13 +33,13 @@ interface OriginalWorkRepository : JpaRepository { ): List /** - * 앱용 원작 목록 조회 + * 앱용 원작 목록 조회 (페이징) * - 소프트 삭제 제외 * - includeAdult=false이면 19금 제외 * - 활성 캐릭터가 하나라도 연결된 원작만 조회 */ @Query( - """ + value = """ SELECT ow FROM OriginalWork ow WHERE ow.isDeleted = false AND (:includeAdult = true OR ow.isAdult = false) @@ -48,7 +48,16 @@ interface OriginalWorkRepository : JpaRepository { WHERE c.originalWork = ow AND c.isActive = true ) ORDER BY ow.createdAt DESC + """, + countQuery = """ + SELECT COUNT(ow) FROM OriginalWork ow + WHERE ow.isDeleted = false + AND (:includeAdult = true OR ow.isAdult = false) + AND EXISTS ( + SELECT 1 FROM ChatCharacter c + WHERE c.originalWork = ow AND c.isActive = true + ) """ ) - fun findAllForApp(@Param("includeAdult") includeAdult: Boolean): List + fun findAllForAppPage(@Param("includeAdult") includeAdult: Boolean, pageable: Pageable): Page } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index e8b6301..62ccf19 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -31,20 +31,23 @@ class OriginalWorkController( ) { /** - * 원작 목록 + * 원작 목록 (페이징) * - 로그인 불필요 * - 본인인증하지 않은 경우 19금 제외 * - 활성 캐릭터가 하나라도 연결된 원작만 노출 + * - 요청: page(기본 0), size(기본 20) * - 반환: totalCount + [imageUrl, title, contentType] */ @GetMapping("/list") fun list( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { val includeAdult = member?.auth != null - val list = queryService.listForApp(includeAdult) - val content = list.map { OriginalWorkListItemResponse.from(it, imageHost) } - ApiResponse.ok(OriginalWorkListResponse(totalCount = content.size.toLong(), content = content)) + val pageRes = queryService.listForAppPage(includeAdult, page, size) + val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) } + ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content)) } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt index 01dc3e6..b6b88f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -21,12 +21,21 @@ class OriginalWorkQueryService( private val chatCharacterRepository: ChatCharacterRepository ) { /** - * 앱용 원작 목록 조회 + * 앱용 원작 목록 조회 (페이징) * @param includeAdult true면 19금 포함, false면 제외 + * @param page 페이지 번호(0부터) + * @param size 페이지 크기(기본 20, 최대 50) */ @Transactional(readOnly = true) - fun listForApp(includeAdult: Boolean): List { - return originalWorkRepository.findAllForApp(includeAdult) + fun listForAppPage(includeAdult: Boolean, page: Int = 0, size: Int = 20): Page { + 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 originalWorkRepository.findAllForAppPage(includeAdult, pageable) } /**