feat(original): 원작 도메인 추가, 관리자 CRUD/연결 API, 소프트 삭제, 서비스 계층 정비
- 원작 엔티티/레포지토리/관리자 API 구축(이미지 S3 업로드 포함) - 캐릭터-원작 연관관계 및 관리자에서 배정/해제 API 제공 - 소프트 삭제(`isDeleted`) 도입 및 조회/수정/배정 로직에서 삭제 항목 필터링 - 컨트롤러-레포지토리 직접 접근 제거, `AdminOriginalWorkService`로 DB 접근 캡슐화 - 캐릭터 등록/수정에서 `originalWorkId` 지원 및 외부 API 업데이트 조건 분리
This commit is contained in:
		| @@ -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.ChatCharacterUpdateRequest | ||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse | 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.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.aws.s3.S3Uploader | ||||||
| import kr.co.vividnext.sodalive.chat.character.CharacterType | import kr.co.vividnext.sodalive.chat.character.CharacterType | ||||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | ||||||
| @@ -37,6 +38,7 @@ class AdminChatCharacterController( | |||||||
|     private val service: ChatCharacterService, |     private val service: ChatCharacterService, | ||||||
|     private val adminService: AdminChatCharacterService, |     private val adminService: AdminChatCharacterService, | ||||||
|     private val s3Uploader: S3Uploader, |     private val s3Uploader: S3Uploader, | ||||||
|  |     private val originalWorkService: AdminOriginalWorkService, | ||||||
|  |  | ||||||
|     @Value("\${weraser.api-key}") |     @Value("\${weraser.api-key}") | ||||||
|     private val apiKey: String, |     private val apiKey: String, | ||||||
| @@ -137,6 +139,11 @@ class AdminChatCharacterController( | |||||||
|         chatCharacter.imagePath = imagePath |         chatCharacter.imagePath = imagePath | ||||||
|         service.saveChatCharacter(chatCharacter) |         service.saveChatCharacter(chatCharacter) | ||||||
|  |  | ||||||
|  |         // 4. 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 | ||||||
|  |         if (request.originalWorkId != null) { | ||||||
|  |             originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         ApiResponse.ok(null) |         ApiResponse.ok(null) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -247,7 +254,8 @@ class AdminChatCharacterController( | |||||||
|         val hasDbOnlyChanges = |         val hasDbOnlyChanges = | ||||||
|             request.originalTitle != null || |             request.originalTitle != null || | ||||||
|                 request.originalLink != null || |                 request.originalLink != null || | ||||||
|                 request.characterType != null |                 request.characterType != null || | ||||||
|  |                 request.originalWorkId != null | ||||||
|  |  | ||||||
|         if (!hasChangedData && !hasImage && !hasDbOnlyChanges) { |         if (!hasChangedData && !hasImage && !hasDbOnlyChanges) { | ||||||
|             throw SodaException("변경된 데이터가 없습니다.") |             throw SodaException("변경된 데이터가 없습니다.") | ||||||
| @@ -286,6 +294,12 @@ class AdminChatCharacterController( | |||||||
|             request = request |             request = request | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |         // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 | ||||||
|  |         if (request.originalWorkId != null) { | ||||||
|  |             // 서비스에서 유효성 검증 및 저장까지 처리 | ||||||
|  |             originalWorkService.assignOneCharacter(request.originalWorkId, request.id) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         ApiResponse.ok(null) |         ApiResponse.ok(null) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -40,6 +40,7 @@ data class ChatCharacterRegisterRequest( | |||||||
|     @JsonProperty("appearance") val appearance: String?, |     @JsonProperty("appearance") val appearance: String?, | ||||||
|     @JsonProperty("originalTitle") val originalTitle: String? = null, |     @JsonProperty("originalTitle") val originalTitle: String? = null, | ||||||
|     @JsonProperty("originalLink") val originalLink: String? = null, |     @JsonProperty("originalLink") val originalLink: String? = null, | ||||||
|  |     @JsonProperty("originalWorkId") val originalWorkId: Long? = null, | ||||||
|     @JsonProperty("characterType") val characterType: String? = null, |     @JsonProperty("characterType") val characterType: String? = null, | ||||||
|     @JsonProperty("tags") val tags: List<String> = emptyList(), |     @JsonProperty("tags") val tags: List<String> = emptyList(), | ||||||
|     @JsonProperty("hobbies") val hobbies: List<String> = emptyList(), |     @JsonProperty("hobbies") val hobbies: List<String> = emptyList(), | ||||||
| @@ -75,6 +76,7 @@ data class ChatCharacterUpdateRequest( | |||||||
|     @JsonProperty("appearance") val appearance: String? = null, |     @JsonProperty("appearance") val appearance: String? = null, | ||||||
|     @JsonProperty("originalTitle") val originalTitle: String? = null, |     @JsonProperty("originalTitle") val originalTitle: String? = null, | ||||||
|     @JsonProperty("originalLink") val originalLink: String? = null, |     @JsonProperty("originalLink") val originalLink: String? = null, | ||||||
|  |     @JsonProperty("originalWorkId") val originalWorkId: Long? = null, | ||||||
|     @JsonProperty("characterType") val characterType: String? = null, |     @JsonProperty("characterType") val characterType: String? = null, | ||||||
|     @JsonProperty("isActive") val isActive: Boolean? = null, |     @JsonProperty("isActive") val isActive: Boolean? = null, | ||||||
|     @JsonProperty("tags") val tags: List<String>? = null, |     @JsonProperty("tags") val tags: List<String>? = null, | ||||||
|   | |||||||
| @@ -0,0 +1,161 @@ | |||||||
|  | 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.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.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: kr.co.vividnext.sodalive.admin.chat.original.service.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("/{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) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 이미지 업로드 공통 처리 */ | ||||||
|  |     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,125 @@ | |||||||
|  | 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.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 pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()) | ||||||
|  |         return originalWorkRepository.findByIsDeletedFalse(pageable) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** 원작에 기존 캐릭터들을 배정 */ | ||||||
|  |     @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 | package kr.co.vividnext.sodalive.chat.character | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.original.OriginalWork | ||||||
| import kr.co.vividnext.sodalive.common.BaseEntity | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
| import javax.persistence.CascadeType | import javax.persistence.CascadeType | ||||||
| import javax.persistence.Column | import javax.persistence.Column | ||||||
| @@ -7,6 +8,8 @@ import javax.persistence.Entity | |||||||
| import javax.persistence.EnumType | import javax.persistence.EnumType | ||||||
| import javax.persistence.Enumerated | import javax.persistence.Enumerated | ||||||
| import javax.persistence.FetchType | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.JoinColumn | ||||||
|  | import javax.persistence.ManyToOne | ||||||
| import javax.persistence.OneToMany | import javax.persistence.OneToMany | ||||||
|  |  | ||||||
| @Entity | @Entity | ||||||
| @@ -44,14 +47,19 @@ class ChatCharacter( | |||||||
|     @Column(columnDefinition = "TEXT") |     @Column(columnDefinition = "TEXT") | ||||||
|     var appearance: String? = null, |     var appearance: String? = null, | ||||||
|  |  | ||||||
|     // 원작 (optional) |     // 원작명/원작링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지) | ||||||
|     @Column(nullable = true) |     @Column(nullable = true) | ||||||
|     var originalTitle: String? = null, |     var originalTitle: String? = null, | ||||||
|  |  | ||||||
|     // 원작 링크 (optional) |     // 원작 링크 (사용하지 않음 - 하위 호환을 위해 필드만 유지) | ||||||
|     @Column(nullable = true) |     @Column(nullable = true) | ||||||
|     var originalLink: String? = null, |     var originalLink: String? = null, | ||||||
|  |  | ||||||
|  |     // 연관 원작 (한 캐릭터는 하나의 원작에만 속함) | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "original_work_id") | ||||||
|  |     var originalWork: OriginalWork? = null, | ||||||
|  |  | ||||||
|     // 캐릭터 유형 |     // 캐릭터 유형 | ||||||
|     @Enumerated(EnumType.STRING) |     @Enumerated(EnumType.STRING) | ||||||
|     @Column(nullable = false) |     @Column(nullable = false) | ||||||
|   | |||||||
| @@ -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,14 @@ | |||||||
|  | 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.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> | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user