feat(original-app): 앱용 원작 목록/상세 API 및 조회 로직 추가
- 공개 목록 API: 미인증 사용자는 19금 비노출, 활성 캐릭터가 1개 이상 연결된 원작만 반환, 총개수+리스트 제공 - 상세 API: 로그인/본인인증 필수, 원작 상세+소속 활성 캐릭터 리스트 반환
This commit is contained in:
		| @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository | |||||||
| interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | ||||||
|     fun findByName(name: String): ChatCharacter? |     fun findByName(name: String): ChatCharacter? | ||||||
|     fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> |     fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> | ||||||
|  |     fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List<ChatCharacter> | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 |      * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 | ||||||
|   | |||||||
| @@ -31,4 +31,24 @@ interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> { | |||||||
|     fun searchNoPaging( |     fun searchNoPaging( | ||||||
|         @Param("searchTerm") searchTerm: String |         @Param("searchTerm") searchTerm: String | ||||||
|     ): List<OriginalWork> |     ): List<OriginalWork> | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 앱용 원작 목록 조회 | ||||||
|  |      * - 소프트 삭제 제외 | ||||||
|  |      * - includeAdult=false이면 19금 제외 | ||||||
|  |      * - 활성 캐릭터가 하나라도 연결된 원작만 조회 | ||||||
|  |      */ | ||||||
|  |     @Query( | ||||||
|  |         """ | ||||||
|  |         SELECT 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 | ||||||
|  |           ) | ||||||
|  |         ORDER BY ow.createdAt DESC | ||||||
|  |         """ | ||||||
|  |     ) | ||||||
|  |     fun findAllForApp(@Param("includeAdult") includeAdult: Boolean): List<OriginalWork> | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,75 @@ | |||||||
|  | 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.OriginalWorkDetailResponse | ||||||
|  | import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse | ||||||
|  | import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse | ||||||
|  | import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService | ||||||
|  | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import kr.co.vividnext.sodalive.member.Member | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | 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.RestController | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 앱용 원작(오리지널 작품) 공개 API | ||||||
|  |  * 1) 목록: 로그인 불필요, 미인증 사용자는 19금 제외, 활성 캐릭터 연결된 원작만 노출 | ||||||
|  |  * 2) 상세: 로그인 + 본인인증 필수 | ||||||
|  |  */ | ||||||
|  | @RestController | ||||||
|  | @RequestMapping("/api/chat/original") | ||||||
|  | class OriginalWorkController( | ||||||
|  |     private val queryService: OriginalWorkQueryService, | ||||||
|  |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|  |     private val imageHost: String | ||||||
|  | ) { | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 원작 목록 | ||||||
|  |      * - 로그인 불필요 | ||||||
|  |      * - 본인인증하지 않은 경우 19금 제외 | ||||||
|  |      * - 활성 캐릭터가 하나라도 연결된 원작만 노출 | ||||||
|  |      * - 반환: totalCount + [imageUrl, title, contentType] | ||||||
|  |      */ | ||||||
|  |     @GetMapping("/list") | ||||||
|  |     fun list( | ||||||
|  |         @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)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 원작 상세 | ||||||
|  |      * - 로그인 및 본인인증 필수 | ||||||
|  |      * - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크 | ||||||
|  |      * - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description] | ||||||
|  |      */ | ||||||
|  |     @GetMapping("/{id}") | ||||||
|  |     fun detail( | ||||||
|  |         @PathVariable id: Long, | ||||||
|  |         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? | ||||||
|  |     ) = run { | ||||||
|  |         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||||
|  |         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||||
|  |  | ||||||
|  |         val ow = queryService.getOriginalWork(id) | ||||||
|  |         val characters = queryService.getActiveCharacters(id).map { | ||||||
|  |             val path = it.imagePath ?: "profile/default-profile.png" | ||||||
|  |             Character( | ||||||
|  |                 characterId = it.id!!, | ||||||
|  |                 name = it.name, | ||||||
|  |                 description = it.description, | ||||||
|  |                 imageUrl = "$imageHost/$path" | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |         val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) | ||||||
|  |         ApiResponse.ok(response) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,77 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.original.dto | ||||||
|  |  | ||||||
|  | import com.fasterxml.jackson.annotation.JsonProperty | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.dto.Character | ||||||
|  | import kr.co.vividnext.sodalive.chat.original.OriginalWork | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 앱용 원작 목록 아이템 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class OriginalWorkListItemResponse( | ||||||
|  |     @JsonProperty("id") val id: Long, | ||||||
|  |     @JsonProperty("imageUrl") val imageUrl: String?, | ||||||
|  |     @JsonProperty("title") val title: String, | ||||||
|  |     @JsonProperty("contentType") val contentType: String | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse { | ||||||
|  |             val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { | ||||||
|  |                 "$imageHost/${entity.imagePath}" | ||||||
|  |             } else { | ||||||
|  |                 entity.imagePath | ||||||
|  |             } | ||||||
|  |             return OriginalWorkListItemResponse( | ||||||
|  |                 id = entity.id!!, | ||||||
|  |                 imageUrl = fullImage, | ||||||
|  |                 title = entity.title, | ||||||
|  |                 contentType = entity.contentType | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 앱용 원작 목록 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class OriginalWorkListResponse( | ||||||
|  |     @JsonProperty("totalCount") val totalCount: Long, | ||||||
|  |     @JsonProperty("content") val content: List<OriginalWorkListItemResponse> | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 앱용 원작 상세 응답 DTO | ||||||
|  |  */ | ||||||
|  | data class OriginalWorkDetailResponse( | ||||||
|  |     @JsonProperty("imageUrl") val imageUrl: String?, | ||||||
|  |     @JsonProperty("title") val title: String, | ||||||
|  |     @JsonProperty("contentType") val contentType: String, | ||||||
|  |     @JsonProperty("category") val category: String, | ||||||
|  |     @JsonProperty("isAdult") val isAdult: Boolean, | ||||||
|  |     @JsonProperty("description") val description: String, | ||||||
|  |     @JsonProperty("originalLink") val originalLink: String?, | ||||||
|  |     @JsonProperty("characters") val characters: List<Character> | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         fun from( | ||||||
|  |             entity: OriginalWork, | ||||||
|  |             imageHost: String = "", | ||||||
|  |             characters: List<Character> | ||||||
|  |         ): OriginalWorkDetailResponse { | ||||||
|  |             val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { | ||||||
|  |                 "$imageHost/${entity.imagePath}" | ||||||
|  |             } else { | ||||||
|  |                 entity.imagePath | ||||||
|  |             } | ||||||
|  |             return OriginalWorkDetailResponse( | ||||||
|  |                 imageUrl = fullImage, | ||||||
|  |                 title = entity.title, | ||||||
|  |                 contentType = entity.contentType, | ||||||
|  |                 category = entity.category, | ||||||
|  |                 isAdult = entity.isAdult, | ||||||
|  |                 description = entity.description, | ||||||
|  |                 originalLink = entity.originalLink, | ||||||
|  |                 characters = characters | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,45 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.original.service | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | ||||||
|  | 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.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 앱 사용자용 원작(오리지널 작품) 조회 서비스 | ||||||
|  |  * - 목록/상세 조회 전용 | ||||||
|  |  */ | ||||||
|  | @Service | ||||||
|  | class OriginalWorkQueryService( | ||||||
|  |     private val originalWorkRepository: OriginalWorkRepository, | ||||||
|  |     private val chatCharacterRepository: ChatCharacterRepository | ||||||
|  | ) { | ||||||
|  |     /** | ||||||
|  |      * 앱용 원작 목록 조회 | ||||||
|  |      * @param includeAdult true면 19금 포함, false면 제외 | ||||||
|  |      */ | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun listForApp(includeAdult: Boolean): List<OriginalWork> { | ||||||
|  |         return originalWorkRepository.findAllForApp(includeAdult) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 원작 상세 조회 (소프트 삭제 제외) | ||||||
|  |      */ | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun getOriginalWork(id: Long): OriginalWork { | ||||||
|  |         return originalWorkRepository.findByIdAndIsDeletedFalse(id) | ||||||
|  |             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 지정 원작에 속한 활성 캐릭터 목록 조회 (최신순) | ||||||
|  |      */ | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun getActiveCharacters(originalWorkId: Long): List<ChatCharacter> { | ||||||
|  |         return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -95,6 +95,7 @@ class SecurityConfig( | |||||||
|             .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() |             .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() | ||||||
|             .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() |             .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() | ||||||
|             .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() |             .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() | ||||||
|  |             .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() | ||||||
|             .anyRequest().authenticated() |             .anyRequest().authenticated() | ||||||
|             .and() |             .and() | ||||||
|             .build() |             .build() | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user