feat(original): 원작 도메인 추가, 관리자 CRUD/연결 API, 소프트 삭제, 서비스 계층 정비
- 원작 엔티티/레포지토리/관리자 API 구축(이미지 S3 업로드 포함) - 캐릭터-원작 연관관계 및 관리자에서 배정/해제 API 제공 - 소프트 삭제(`isDeleted`) 도입 및 조회/수정/배정 로직에서 삭제 항목 필터링 - 컨트롤러-레포지토리 직접 접근 제거, `AdminOriginalWorkService`로 DB 접근 캡슐화 - 캐릭터 등록/수정에서 `originalWorkId` 지원 및 외부 API 업데이트 조건 분리
This commit is contained in:
parent
0574f4f629
commit
4904625488
|
@ -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>
|
||||||
|
}
|
Loading…
Reference in New Issue