test #341
| @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRe | ||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest | ||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse | ||||
| import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService | ||||
| import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||
| import kr.co.vividnext.sodalive.chat.character.CharacterType | ||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | ||||
| @@ -37,6 +38,7 @@ class AdminChatCharacterController( | ||||
|     private val service: ChatCharacterService, | ||||
|     private val adminService: AdminChatCharacterService, | ||||
|     private val s3Uploader: S3Uploader, | ||||
|     private val originalWorkService: AdminOriginalWorkService, | ||||
|  | ||||
|     @Value("\${weraser.api-key}") | ||||
|     private val apiKey: String, | ||||
| @@ -68,6 +70,19 @@ class AdminChatCharacterController( | ||||
|         ApiResponse.ok(response) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 캐릭터 검색(관리자) | ||||
|      * - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상 | ||||
|      * - 페이징 제거: 전체 목록 반환 | ||||
|      */ | ||||
|     @GetMapping("/search") | ||||
|     fun searchCharacters( | ||||
|         @RequestParam("searchTerm") searchTerm: String | ||||
|     ) = run { | ||||
|         val list = adminService.searchCharactersAll(searchTerm, imageHost) | ||||
|         ApiResponse.ok(list) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 캐릭터 상세 정보 조회 API | ||||
|      * | ||||
| @@ -137,6 +152,11 @@ class AdminChatCharacterController( | ||||
|         chatCharacter.imagePath = imagePath | ||||
|         service.saveChatCharacter(chatCharacter) | ||||
|  | ||||
|         // 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 | ||||
|         if (request.originalWorkId != null) { | ||||
|             originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!) | ||||
|         } | ||||
|  | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
| @@ -247,7 +267,8 @@ class AdminChatCharacterController( | ||||
|         val hasDbOnlyChanges = | ||||
|             request.originalTitle != null || | ||||
|                 request.originalLink != null || | ||||
|                 request.characterType != null | ||||
|                 request.characterType != null || | ||||
|                 request.originalWorkId != null | ||||
|  | ||||
|         if (!hasChangedData && !hasImage && !hasDbOnlyChanges) { | ||||
|             throw SodaException("변경된 데이터가 없습니다.") | ||||
| @@ -286,6 +307,12 @@ class AdminChatCharacterController( | ||||
|             request = request | ||||
|         ) | ||||
|  | ||||
|         // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 | ||||
|         if (request.originalWorkId != null) { | ||||
|             // 서비스에서 유효성 검증 및 저장까지 처리 | ||||
|             originalWorkService.assignOneCharacter(request.originalWorkId, request.id) | ||||
|         } | ||||
|  | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||
|  | ||||
| /** | ||||
|  * 관리자 캐릭터 상세 응답 DTO | ||||
|  * - 원작이 연결되어 있으면 원작 요약 정보(originalWork)를 함께 반환한다. | ||||
|  */ | ||||
| data class ChatCharacterDetailResponse( | ||||
|     val id: Long, | ||||
|     val characterUUID: String, | ||||
| @@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse( | ||||
|     val relationships: List<RelationshipResponse>, | ||||
|     val personalities: List<PersonalityResponse>, | ||||
|     val backgrounds: List<BackgroundResponse>, | ||||
|     val memories: List<MemoryResponse> | ||||
|     val memories: List<MemoryResponse>, | ||||
|     val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보 | ||||
| ) { | ||||
|     companion object { | ||||
|         fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse { | ||||
| @@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse( | ||||
|                 chatCharacter.imagePath ?: "" | ||||
|             } | ||||
|  | ||||
|             val ow = chatCharacter.originalWork | ||||
|             val originalWorkBrief = ow?.let { | ||||
|                 val owImage = if (it.imagePath != null && imageHost.isNotEmpty()) { | ||||
|                     "$imageHost/${it.imagePath}" | ||||
|                 } else { | ||||
|                     it.imagePath | ||||
|                 } | ||||
|                 OriginalWorkBriefResponse( | ||||
|                     id = it.id!!, | ||||
|                     imageUrl = owImage, | ||||
|                     title = it.title | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             return ChatCharacterDetailResponse( | ||||
|                 id = chatCharacter.id!!, | ||||
|                 characterUUID = chatCharacter.characterUUID, | ||||
| @@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse( | ||||
|                 }, | ||||
|                 memories = chatCharacter.memories.map { | ||||
|                     MemoryResponse(it.title, it.content, it.emotion) | ||||
|                 } | ||||
|                 }, | ||||
|                 originalWork = originalWorkBrief | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| @@ -101,3 +121,12 @@ data class RelationshipResponse( | ||||
|     val relationshipType: String, | ||||
|     val currentStatus: String | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 원작 요약 응답 DTO(관리자 캐릭터 상세용) | ||||
|  */ | ||||
| data class OriginalWorkBriefResponse( | ||||
|     val id: Long, | ||||
|     val imageUrl: String?, | ||||
|     val title: String | ||||
| ) | ||||
|   | ||||
| @@ -40,6 +40,7 @@ data class ChatCharacterRegisterRequest( | ||||
|     @JsonProperty("appearance") val appearance: String?, | ||||
|     @JsonProperty("originalTitle") val originalTitle: String? = null, | ||||
|     @JsonProperty("originalLink") val originalLink: String? = null, | ||||
|     @JsonProperty("originalWorkId") val originalWorkId: Long? = null, | ||||
|     @JsonProperty("characterType") val characterType: String? = null, | ||||
|     @JsonProperty("tags") val tags: List<String> = emptyList(), | ||||
|     @JsonProperty("hobbies") val hobbies: List<String> = emptyList(), | ||||
| @@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest( | ||||
|     @JsonProperty("appearance") val appearance: String? = null, | ||||
|     @JsonProperty("originalTitle") val originalTitle: String? = null, | ||||
|     @JsonProperty("originalLink") val originalLink: String? = null, | ||||
|     @JsonProperty("originalWorkId") val originalWorkId: Long? = null, | ||||
|     @JsonProperty("characterType") val characterType: String? = null, | ||||
|     @JsonProperty("isActive") val isActive: Boolean? = null, | ||||
|     @JsonProperty("tags") val tags: List<String>? = null, | ||||
|   | ||||
| @@ -65,12 +65,7 @@ class AdminChatCharacterService( | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) | ||||
|      * | ||||
|      * @param searchTerm 검색어 | ||||
|      * @param pageable 페이징 정보 | ||||
|      * @param imageHost 이미지 호스트 URL | ||||
|      * @return 검색된 캐릭터 목록 (페이징) | ||||
|      * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용) | ||||
|      */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun searchCharacters( | ||||
| @@ -81,4 +76,20 @@ class AdminChatCharacterService( | ||||
|         val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) | ||||
|         return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 무페이징 | ||||
|      * | ||||
|      * @param searchTerm 검색어 | ||||
|      * @param imageHost 이미지 호스트 URL | ||||
|      * @return 검색된 캐릭터 목록 (전체) | ||||
|      */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun searchCharactersAll( | ||||
|         searchTerm: String, | ||||
|         imageHost: String = "" | ||||
|     ): List<ChatCharacterSearchResponse> { | ||||
|         val characters = chatCharacterRepository.searchCharactersNoPaging(searchTerm) | ||||
|         return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,199 @@ | ||||
| package kr.co.vividnext.sodalive.admin.chat.original | ||||
|  | ||||
| import com.amazonaws.services.s3.model.ObjectMetadata | ||||
| import com.fasterxml.jackson.databind.ObjectMapper | ||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse | ||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService | ||||
| import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.utils.generateFileName | ||||
| import org.springframework.beans.factory.annotation.Value | ||||
| import org.springframework.security.access.prepost.PreAuthorize | ||||
| import org.springframework.web.bind.annotation.DeleteMapping | ||||
| import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.PostMapping | ||||
| import org.springframework.web.bind.annotation.PutMapping | ||||
| import org.springframework.web.bind.annotation.RequestBody | ||||
| import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RequestPart | ||||
| import org.springframework.web.bind.annotation.RestController | ||||
| import org.springframework.web.multipart.MultipartFile | ||||
|  | ||||
| /** | ||||
|  * 원작(오리지널 작품) 관리자 API | ||||
|  * - 원작 등록/수정/삭제 | ||||
|  * - 원작과 캐릭터 연결(배정) 및 해제 | ||||
|  */ | ||||
| @RestController | ||||
| @RequestMapping("/admin/chat/original") | ||||
| @PreAuthorize("hasRole('ADMIN')") | ||||
| class AdminOriginalWorkController( | ||||
|     private val originalWorkService: AdminOriginalWorkService, | ||||
|     private val s3Uploader: S3Uploader, | ||||
|     @Value("\${cloud.aws.s3.bucket}") | ||||
|     private val s3Bucket: String, | ||||
|     @Value("\${cloud.aws.cloud-front.host}") | ||||
|     private val imageHost: String | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
|      * 원작 등록 | ||||
|      * - 이미지 파일과 JSON 요청을 멀티파트로 받는다. | ||||
|      */ | ||||
|     @PostMapping("/register") | ||||
|     fun register( | ||||
|         @RequestPart("image") image: MultipartFile, | ||||
|         @RequestPart("request") requestString: String | ||||
|     ) = run { | ||||
|         val objectMapper = ObjectMapper() | ||||
|         val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java) | ||||
|  | ||||
|         // 서비스 계층을 통해 원작을 생성 | ||||
|         val saved = originalWorkService.createOriginalWork(request) | ||||
|  | ||||
|         // 이미지 업로드 후 이미지 경로 업데이트 | ||||
|         val imagePath = uploadImage(saved.id!!, image) | ||||
|         originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath) | ||||
|  | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작 수정 | ||||
|      * - 이미지가 있으면 교체, 없으면 유지 | ||||
|      */ | ||||
|     @PutMapping("/update") | ||||
|     fun update( | ||||
|         @RequestPart(value = "image", required = false) image: MultipartFile?, | ||||
|         @RequestPart("request") requestString: String | ||||
|     ) = run { | ||||
|         val objectMapper = ObjectMapper() | ||||
|         val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java) | ||||
|  | ||||
|         // 이미지가 전달된 경우 먼저 업로드하여 경로를 생성 | ||||
|         val imagePath = if (image != null && !image.isEmpty) { | ||||
|             uploadImage(request.id, image) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|         originalWorkService.updateOriginalWork(request, imagePath) | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작 삭제 | ||||
|      */ | ||||
|     @DeleteMapping("/{id}") | ||||
|     fun delete(@PathVariable id: Long) = run { | ||||
|         originalWorkService.deleteOriginalWork(id) | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작 목록(페이징) | ||||
|      */ | ||||
|     @GetMapping("/list") | ||||
|     fun list( | ||||
|         @RequestParam(defaultValue = "0") page: Int, | ||||
|         @RequestParam(defaultValue = "20") size: Int | ||||
|     ) = run { | ||||
|         val pageRes = originalWorkService.getOriginalWorkPage(page, size) | ||||
|         val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) } | ||||
|         ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작 검색(관리자) | ||||
|      * - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외 | ||||
|      * - 페이징 제거: 전체 목록 반환 | ||||
|      */ | ||||
|     @GetMapping("/search") | ||||
|     fun search( | ||||
|         @RequestParam("searchTerm") searchTerm: String | ||||
|     ) = run { | ||||
|         val list = originalWorkService.searchOriginalWorksAll(searchTerm) | ||||
|         val content = list.map { OriginalWorkResponse.from(it, imageHost) } | ||||
|         ApiResponse.ok(content) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작 상세 | ||||
|      */ | ||||
|     @GetMapping("/{id}") | ||||
|     fun detail(@PathVariable id: Long) = run { | ||||
|         ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost)) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작에 기존 캐릭터들을 배정 | ||||
|      * - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 이 원작으로 설정 | ||||
|      */ | ||||
|     @PostMapping("/{id}/assign-characters") | ||||
|     fun assignCharacters( | ||||
|         @PathVariable id: Long, | ||||
|         @RequestBody body: OriginalWorkAssignCharactersRequest | ||||
|     ) = run { | ||||
|         originalWorkService.assignCharacters(id, body.characterIds) | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 원작에서 캐릭터들 해제 | ||||
|      * - 캐릭터들의 originalWork를 null로 설정 | ||||
|      */ | ||||
|     @PostMapping("/{id}/unassign-characters") | ||||
|     fun unassignCharacters( | ||||
|         @PathVariable id: Long, | ||||
|         @RequestBody body: OriginalWorkAssignCharactersRequest | ||||
|     ) = run { | ||||
|         originalWorkService.unassignCharacters(id, body.characterIds) | ||||
|         ApiResponse.ok(null) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회 | ||||
|      * - 활성 캐릭터만 포함 | ||||
|      * - 응답 항목: 캐릭터 이미지(URL), 이름 | ||||
|      */ | ||||
|     @GetMapping("/{id}/characters") | ||||
|     fun listCharactersOfOriginal( | ||||
|         @PathVariable id: Long, | ||||
|         @RequestParam(defaultValue = "0") page: Int, | ||||
|         @RequestParam(defaultValue = "20") size: Int | ||||
|     ) = run { | ||||
|         val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size) | ||||
|         val content = pageRes.content.map { ChatCharacterSearchResponse.from(it, imageHost) } | ||||
|         ApiResponse.ok( | ||||
|             ChatCharacterSearchListPageResponse( | ||||
|                 totalCount = pageRes.totalElements, | ||||
|                 content = content | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** 이미지 업로드 공통 처리 */ | ||||
|     private fun uploadImage(originalWorkId: Long, image: MultipartFile): String { | ||||
|         try { | ||||
|             val metadata = ObjectMetadata() | ||||
|             metadata.contentLength = image.size | ||||
|             return s3Uploader.upload( | ||||
|                 inputStream = image.inputStream, | ||||
|                 bucket = s3Bucket, | ||||
|                 filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}", | ||||
|                 metadata = metadata | ||||
|             ) | ||||
|         } catch (e: Exception) { | ||||
|             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,75 @@ | ||||
| package kr.co.vividnext.sodalive.admin.chat.original.dto | ||||
|  | ||||
| import com.fasterxml.jackson.annotation.JsonProperty | ||||
| import kr.co.vividnext.sodalive.chat.original.OriginalWork | ||||
|  | ||||
| /** | ||||
|  * 원작 등록 요청 DTO | ||||
|  */ | ||||
| data class OriginalWorkRegisterRequest( | ||||
|     @JsonProperty("title") val title: String, | ||||
|     @JsonProperty("contentType") val contentType: String, | ||||
|     @JsonProperty("category") val category: String, | ||||
|     @JsonProperty("isAdult") val isAdult: Boolean = false, | ||||
|     @JsonProperty("description") val description: String = "", | ||||
|     @JsonProperty("originalLink") val originalLink: String? = null | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 원작 수정 요청 DTO (부분 수정 가능) | ||||
|  */ | ||||
| data class OriginalWorkUpdateRequest( | ||||
|     @JsonProperty("id") val id: Long, | ||||
|     @JsonProperty("title") val title: String? = null, | ||||
|     @JsonProperty("contentType") val contentType: String? = null, | ||||
|     @JsonProperty("category") val category: String? = null, | ||||
|     @JsonProperty("isAdult") val isAdult: Boolean? = null, | ||||
|     @JsonProperty("description") val description: String? = null, | ||||
|     @JsonProperty("originalLink") val originalLink: String? = null | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 원작 상세/목록 응답 DTO | ||||
|  */ | ||||
| data class OriginalWorkResponse( | ||||
|     val id: Long, | ||||
|     val title: String, | ||||
|     val contentType: String, | ||||
|     val category: String, | ||||
|     val isAdult: Boolean, | ||||
|     val description: String, | ||||
|     val originalLink: String?, | ||||
|     val imageUrl: String? | ||||
| ) { | ||||
|     companion object { | ||||
|         fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkResponse { | ||||
|             val fullImagePath = if (entity.imagePath != null && imageHost.isNotEmpty()) { | ||||
|                 "$imageHost/${entity.imagePath}" | ||||
|             } else { | ||||
|                 entity.imagePath | ||||
|             } | ||||
|             return OriginalWorkResponse( | ||||
|                 id = entity.id!!, | ||||
|                 title = entity.title, | ||||
|                 contentType = entity.contentType, | ||||
|                 category = entity.category, | ||||
|                 isAdult = entity.isAdult, | ||||
|                 description = entity.description, | ||||
|                 originalLink = entity.originalLink, | ||||
|                 imageUrl = fullImagePath | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| data class OriginalWorkPageResponse( | ||||
|     val totalCount: Long, | ||||
|     val content: List<OriginalWorkResponse> | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 원작-캐릭터 연결/해제 요청 DTO | ||||
|  */ | ||||
| data class OriginalWorkAssignCharactersRequest( | ||||
|     @JsonProperty("characterIds") val characterIds: List<Long> | ||||
| ) | ||||
| @@ -0,0 +1,155 @@ | ||||
| package kr.co.vividnext.sodalive.admin.chat.original.service | ||||
|  | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest | ||||
| import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest | ||||
| 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.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 | ||||
|  | ||||
| /** | ||||
|  * 원작(오리지널 작품) 관련 관리자 서비스 | ||||
|  * - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다. | ||||
|  */ | ||||
| @Service | ||||
| class AdminOriginalWorkService( | ||||
|     private val originalWorkRepository: OriginalWorkRepository, | ||||
|     private val chatCharacterRepository: ChatCharacterRepository | ||||
| ) { | ||||
|  | ||||
|     /** 원작 등록 (중복 제목 방지 포함) */ | ||||
|     @Transactional | ||||
|     fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork { | ||||
|         originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let { | ||||
|             throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}") | ||||
|         } | ||||
|         val entity = OriginalWork( | ||||
|             title = request.title, | ||||
|             contentType = request.contentType, | ||||
|             category = request.category, | ||||
|             isAdult = request.isAdult, | ||||
|             description = request.description, | ||||
|             originalLink = request.originalLink | ||||
|         ) | ||||
|         return originalWorkRepository.save(entity) | ||||
|     } | ||||
|  | ||||
|     /** 원작 수정 (이미지 경로 포함 선택적 변경) */ | ||||
|     @Transactional | ||||
|     fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork { | ||||
|         val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|  | ||||
|         request.title?.let { ow.title = it } | ||||
|         request.contentType?.let { ow.contentType = it } | ||||
|         request.category?.let { ow.category = it } | ||||
|         request.isAdult?.let { ow.isAdult = it } | ||||
|         request.description?.let { ow.description = it } | ||||
|         request.originalLink?.let { ow.originalLink = it } | ||||
|         if (imagePath != null) { | ||||
|             ow.imagePath = imagePath | ||||
|         } | ||||
|         return originalWorkRepository.save(ow) | ||||
|     } | ||||
|  | ||||
|     /** 원작 이미지 경로만 별도 갱신 */ | ||||
|     @Transactional | ||||
|     fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork { | ||||
|         val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|         ow.imagePath = imagePath | ||||
|         return originalWorkRepository.save(ow) | ||||
|     } | ||||
|  | ||||
|     /** 원작 삭제 (소프트 삭제) */ | ||||
|     @Transactional | ||||
|     fun deleteOriginalWork(id: Long) { | ||||
|         val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") } | ||||
|         ow.isDeleted = true | ||||
|         originalWorkRepository.save(ow) | ||||
|     } | ||||
|  | ||||
|     /** 원작 상세 조회 (소프트 삭제 제외) */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun getOriginalWork(id: Long): OriginalWork { | ||||
|         return originalWorkRepository.findByIdAndIsDeletedFalse(id) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|     } | ||||
|  | ||||
|     /** 원작 페이징 조회 */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> { | ||||
|         val safePage = if (page < 0) 0 else page | ||||
|         val safeSize = when { | ||||
|             size <= 0 -> 20 | ||||
|             size > 100 -> 100 | ||||
|             else -> size | ||||
|         } | ||||
|         val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) | ||||
|         return originalWorkRepository.findByIsDeletedFalse(pageable) | ||||
|     } | ||||
|  | ||||
|     /** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> { | ||||
|         // 원작 존재 및 소프트 삭제 여부 확인 | ||||
|         originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|  | ||||
|         val safePage = if (page < 0) 0 else page | ||||
|         val safeSize = when { | ||||
|             size <= 0 -> 20 | ||||
|             size > 100 -> 100 | ||||
|             else -> size | ||||
|         } | ||||
|         val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) | ||||
|         return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable) | ||||
|     } | ||||
|  | ||||
|     /** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */ | ||||
|     @Transactional(readOnly = true) | ||||
|     fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> { | ||||
|         return originalWorkRepository.searchNoPaging(searchTerm) | ||||
|     } | ||||
|  | ||||
|     /** 원작에 기존 캐릭터들을 배정 */ | ||||
|     @Transactional | ||||
|     fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) { | ||||
|         val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|         if (characterIds.isEmpty()) return | ||||
|         val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds) | ||||
|         characters.forEach { it.originalWork = ow } | ||||
|         chatCharacterRepository.saveAll(characters) | ||||
|     } | ||||
|  | ||||
|     /** 원작에서 캐릭터들 해제 */ | ||||
|     @Transactional | ||||
|     fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) { | ||||
|         // 원작 존재 확인 (소프트 삭제 제외) | ||||
|         originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|         if (characterIds.isEmpty()) return | ||||
|         val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds) | ||||
|         characters.forEach { it.originalWork = null } | ||||
|         chatCharacterRepository.saveAll(characters) | ||||
|     } | ||||
|  | ||||
|     /** 단일 캐릭터를 지정 원작에 배정 */ | ||||
|     @Transactional | ||||
|     fun assignOneCharacter(originalWorkId: Long, characterId: Long) { | ||||
|         val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId) | ||||
|             .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } | ||||
|         val character = chatCharacterRepository.findById(characterId) | ||||
|             .orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") } | ||||
|         character.originalWork = ow | ||||
|         chatCharacterRepository.save(character) | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| package kr.co.vividnext.sodalive.chat.character | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.original.OriginalWork | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.CascadeType | ||||
| import javax.persistence.Column | ||||
| @@ -7,6 +8,8 @@ import javax.persistence.Entity | ||||
| import javax.persistence.EnumType | ||||
| import javax.persistence.Enumerated | ||||
| import javax.persistence.FetchType | ||||
| import javax.persistence.JoinColumn | ||||
| import javax.persistence.ManyToOne | ||||
| import javax.persistence.OneToMany | ||||
|  | ||||
| @Entity | ||||
| @@ -44,14 +47,19 @@ class ChatCharacter( | ||||
|     @Column(columnDefinition = "TEXT") | ||||
|     var appearance: String? = null, | ||||
|  | ||||
|     // 원작 (optional) | ||||
|     // 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지) | ||||
|     @Column(nullable = true) | ||||
|     var originalTitle: String? = null, | ||||
|  | ||||
|     // 원작 링크 (optional) | ||||
|     // 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지) | ||||
|     @Column(nullable = true) | ||||
|     var originalLink: String? = null, | ||||
|  | ||||
|     // 연관 원작 (한 캐릭터는 하나의 원작에만 속함) | ||||
|     @ManyToOne(fetch = FetchType.LAZY) | ||||
|     @JoinColumn(name = "original_work_id") | ||||
|     var originalWork: OriginalWork? = null, | ||||
|  | ||||
|     // 캐릭터 유형 | ||||
|     @Enumerated(EnumType.STRING) | ||||
|     @Column(nullable = false) | ||||
|   | ||||
| @@ -12,6 +12,8 @@ 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> | ||||
|     fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter> | ||||
|  | ||||
|     /** | ||||
|      * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 | ||||
| @@ -31,7 +33,7 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | ||||
|     fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long | ||||
|  | ||||
|     /** | ||||
|      * 이름, 설명, MBTI, 태그로 캐릭터 검색 | ||||
|      * 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징 | ||||
|      */ | ||||
|     @Query( | ||||
|         """ | ||||
| @@ -52,6 +54,28 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { | ||||
|         pageable: Pageable | ||||
|     ): Page<ChatCharacter> | ||||
|  | ||||
|     /** | ||||
|      * 이름, 설명, MBTI, 태그로 캐릭터 검색 - 무페이징 전체 목록 | ||||
|      */ | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT DISTINCT c FROM ChatCharacter c | ||||
|         LEFT JOIN c.tagMappings tm | ||||
|         LEFT JOIN tm.tag t | ||||
|         WHERE c.isActive = true AND | ||||
|         ( | ||||
|             LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||
|             LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||
|             (c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR | ||||
|             (t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) | ||||
|         ) | ||||
|         ORDER BY c.createdAt DESC | ||||
|         """ | ||||
|     ) | ||||
|     fun searchCharactersNoPaging( | ||||
|         @Param("searchTerm") searchTerm: String | ||||
|     ): List<ChatCharacter> | ||||
|  | ||||
|     /** | ||||
|      * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) | ||||
|      */ | ||||
|   | ||||
| @@ -45,10 +45,10 @@ class ChatCharacterService( | ||||
|     @Transactional(readOnly = true) | ||||
|     @Cacheable( | ||||
|         cacheNames = ["popularCharacters_24h"], | ||||
|         key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey" | ||||
|         key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey" | ||||
|     ) | ||||
|     fun getPopularCharacters(limit: Long = 20): List<Character> { | ||||
|         val window = RankingWindowCalculator.now("popular-chat-character") | ||||
|         val window = RankingWindowCalculator.now("popular-character") | ||||
|         val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) | ||||
|         val list = loadCharactersInOrder(topIds) | ||||
|         return list.map { | ||||
|   | ||||
| @@ -20,7 +20,7 @@ object RankingWindowCalculator { | ||||
|     private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC | ||||
|  | ||||
|     @JvmStatic | ||||
|     fun now(prefix: String = "popular-chat-character"): RankingWindow { | ||||
|     fun now(prefix: String = "popular-character"): RankingWindow { | ||||
|         val now = ZonedDateTime.now(ZONE) | ||||
|         val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE) | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,43 @@ | ||||
| package kr.co.vividnext.sodalive.chat.original | ||||
|  | ||||
| import kr.co.vividnext.sodalive.common.BaseEntity | ||||
| import javax.persistence.Column | ||||
| import javax.persistence.Entity | ||||
|  | ||||
| /** | ||||
|  * 원작(오리지널 작품) 엔티티 | ||||
|  * - 캐릭터를 원작별로 묶기 위한 기준 엔티티 | ||||
|  * - 각 필드는 운영에서 관리자가 입력/수정한다. | ||||
|  */ | ||||
| @Entity | ||||
| class OriginalWork( | ||||
|     /** 원작 제목 */ | ||||
|     @Column(nullable = false) | ||||
|     var title: String, | ||||
|  | ||||
|     /** 콘텐츠 타입 (예: 웹소설, 웹툰 등) */ | ||||
|     @Column(nullable = false) | ||||
|     var contentType: String, | ||||
|  | ||||
|     /** 카테고리/장르 (예: 로맨스, 판타지 등) */ | ||||
|     @Column(nullable = false) | ||||
|     var category: String, | ||||
|  | ||||
|     /** 19금 여부 */ | ||||
|     @Column(nullable = false) | ||||
|     var isAdult: Boolean = false, | ||||
|  | ||||
|     /** 작품 소개 */ | ||||
|     @Column(columnDefinition = "TEXT") | ||||
|     var description: String = "", | ||||
|  | ||||
|     /** 원작 링크 */ | ||||
|     @Column(nullable = true) | ||||
|     var originalLink: String? = null | ||||
| ) : BaseEntity() { | ||||
|     /** 원작 대표 이미지 S3 경로 */ | ||||
|     var imagePath: String? = null | ||||
|  | ||||
|     /** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */ | ||||
|     var isDeleted: Boolean = false | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| package kr.co.vividnext.sodalive.chat.original | ||||
|  | ||||
| import org.springframework.data.domain.Page | ||||
| import org.springframework.data.domain.Pageable | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | ||||
| import java.util.Optional | ||||
|  | ||||
| @Repository | ||||
| interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> { | ||||
|     fun findByTitleAndIsDeletedFalse(title: String): OriginalWork? | ||||
|     fun findByIdAndIsDeletedFalse(id: Long): Optional<OriginalWork> | ||||
|     fun findByIsDeletedFalse(pageable: Pageable): Page<OriginalWork> | ||||
|  | ||||
|     /** | ||||
|      * 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록 | ||||
|      */ | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT ow FROM OriginalWork ow | ||||
|         WHERE ow.isDeleted = false AND ( | ||||
|             LOWER(ow.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||
|             LOWER(ow.contentType) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR | ||||
|             LOWER(ow.category) LIKE LOWER(CONCAT('%', :searchTerm, '%')) | ||||
|         ) | ||||
|         ORDER BY ow.createdAt DESC | ||||
|         """ | ||||
|     ) | ||||
|     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() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user