feat(original-app): 앱용 원작 목록/상세 API 및 조회 로직 추가
- 공개 목록 API: 미인증 사용자는 19금 비노출, 활성 캐릭터가 1개 이상 연결된 원작만 반환, 총개수+리스트 제공 - 상세 API: 로그인/본인인증 필수, 원작 상세+소속 활성 캐릭터 리스트 반환
This commit is contained in:
parent
b6c96af8a2
commit
3b148d549e
|
@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository
|
|||
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
fun findByName(name: String): ChatCharacter?
|
||||
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
|
||||
fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List<ChatCharacter>
|
||||
|
||||
/**
|
||||
* 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
|
||||
|
|
|
@ -31,4 +31,24 @@ interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
|
|||
fun searchNoPaging(
|
||||
@Param("searchTerm") searchTerm: String
|
||||
): 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, "/api/chat/character/main").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.build()
|
||||
|
|
Loading…
Reference in New Issue