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 f321f99..9daacec 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 @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByName(name: String): ChatCharacter? fun findByIsActiveTrue(pageable: Pageable): Page + fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List /** * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 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 4cac026..97856f5 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 @@ -31,4 +31,24 @@ interface OriginalWorkRepository : JpaRepository { fun searchNoPaging( @Param("searchTerm") searchTerm: String ): List + + /** + * 앱용 원작 목록 조회 + * - 소프트 삭제 제외 + * - 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 } 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 new file mode 100644 index 0000000..4b7dd4b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -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) + } +} 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 new file mode 100644 index 0000000..9c38622 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -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 +) + +/** + * 앱용 원작 상세 응답 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 +) { + companion object { + fun from( + entity: OriginalWork, + imageHost: String = "", + characters: List + ): 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 + ) + } + } +} 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 new file mode 100644 index 0000000..1377dab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -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 { + 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 { + return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 39142aa..cc42fbb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -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()