From 49046254884a730578a49099553bf335494b14ef Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 22:26:33 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(original):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20CRUD/=EC=97=B0=EA=B2=B0=20API,=20=EC=86=8C?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=82=AD=EC=A0=9C,=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=84=EC=B8=B5=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원작 엔티티/레포지토리/관리자 API 구축(이미지 S3 업로드 포함) - 캐릭터-원작 연관관계 및 관리자에서 배정/해제 API 제공 - 소프트 삭제(`isDeleted`) 도입 및 조회/수정/배정 로직에서 삭제 항목 필터링 - 컨트롤러-레포지토리 직접 접근 제거, `AdminOriginalWorkService`로 DB 접근 캡슐화 - 캐릭터 등록/수정에서 `originalWorkId` 지원 및 외부 API 업데이트 조건 분리 --- .../character/AdminChatCharacterController.kt | 16 +- .../chat/character/dto/ChatCharacterDto.kt | 2 + .../original/AdminOriginalWorkController.kt | 161 ++++++++++++++++++ .../chat/original/dto/OriginalWorkDtos.kt | 75 ++++++++ .../service/AdminOriginalWorkService.kt | 125 ++++++++++++++ .../sodalive/chat/character/ChatCharacter.kt | 12 +- .../sodalive/chat/original/OriginalWork.kt | 43 +++++ .../chat/original/OriginalWorkRepository.kt | 14 ++ 8 files changed, 445 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index f248fa4..3718672 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -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, @@ -137,6 +139,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 +254,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 +294,12 @@ class AdminChatCharacterController( request = request ) + // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 + if (request.originalWorkId != null) { + // 서비스에서 유효성 검증 및 저장까지 처리 + originalWorkService.assignOneCharacter(request.originalWorkId, request.id) + } + ApiResponse.ok(null) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt index 0710330..ca203eb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt @@ -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 = emptyList(), @JsonProperty("hobbies") val hobbies: List = 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? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt new file mode 100644 index 0000000..c307626 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt @@ -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}") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt new file mode 100644 index 0000000..263582c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt @@ -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 +) + +/** + * 원작-캐릭터 연결/해제 요청 DTO + */ +data class OriginalWorkAssignCharactersRequest( + @JsonProperty("characterIds") val characterIds: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt new file mode 100644 index 0000000..78b009f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -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 { + val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()) + return originalWorkRepository.findByIsDeletedFalse(pageable) + } + + /** 원작에 기존 캐릭터들을 배정 */ + @Transactional + fun assignCharacters(originalWorkId: Long, characterIds: List) { + 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) { + // 원작 존재 확인 (소프트 삭제 제외) + 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) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 96738f0..981b6f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt new file mode 100644 index 0000000..35dc383 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt @@ -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 +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt new file mode 100644 index 0000000..8b69441 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -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 { + fun findByTitleAndIsDeletedFalse(title: String): OriginalWork? + fun findByIdAndIsDeletedFalse(id: Long): Optional + fun findByIsDeletedFalse(pageable: Pageable): Page +} From b6c96af8a2e0e30e300b6605bec0156f3c7fd511 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 23:00:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(original):=20=EC=9B=90=EC=9E=91=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EA=B3=BC=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EA=B0=81=EA=B0=81=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character/AdminChatCharacterController.kt | 13 ++++++++++ .../service/AdminChatCharacterService.kt | 23 +++++++++++++----- .../original/AdminOriginalWorkController.kt | 14 +++++++++++ .../service/AdminOriginalWorkService.kt | 6 +++++ .../repository/ChatCharacterRepository.kt | 24 ++++++++++++++++++- .../chat/original/OriginalWorkRepository.kt | 20 ++++++++++++++++ 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 3718672..38db366 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -70,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 * diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt index 07b7c7e..851f345 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt @@ -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 { + val characters = chatCharacterRepository.searchCharactersNoPaging(searchTerm) + return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt index c307626..a6f90a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt @@ -109,6 +109,20 @@ class AdminOriginalWorkController( 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) + } + /** * 원작 상세 */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index 78b009f..212e7b9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -89,6 +89,12 @@ class AdminOriginalWorkService( return originalWorkRepository.findByIsDeletedFalse(pageable) } + /** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */ + @Transactional(readOnly = true) + fun searchOriginalWorksAll(searchTerm: String): List { + return originalWorkRepository.searchNoPaging(searchTerm) + } + /** 원작에 기존 캐릭터들을 배정 */ @Transactional fun assignCharacters(originalWorkId: Long, characterIds: List) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index 20b74b9..f321f99 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -31,7 +31,7 @@ interface ChatCharacterRepository : JpaRepository { fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long /** - * 이름, 설명, MBTI, 태그로 캐릭터 검색 + * 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징 */ @Query( """ @@ -52,6 +52,28 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): Page + /** + * 이름, 설명, 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 + /** * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt index 8b69441..4cac026 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -3,6 +3,8 @@ 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 @@ -11,4 +13,22 @@ interface OriginalWorkRepository : JpaRepository { fun findByTitleAndIsDeletedFalse(title: String): OriginalWork? fun findByIdAndIsDeletedFalse(id: Long): Optional fun findByIsDeletedFalse(pageable: Pageable): Page + + /** + * 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록 + */ + @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 } From 3b148d549efe3530f8c8fe7b924d609067756534 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 14 Sep 2025 23:27:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(original-app):=20=EC=95=B1=EC=9A=A9=20?= =?UTF-8?q?=EC=9B=90=EC=9E=91=20=EB=AA=A9=EB=A1=9D/=EC=83=81=EC=84=B8=20AP?= =?UTF-8?q?I=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공개 목록 API: 미인증 사용자는 19금 비노출, 활성 캐릭터가 1개 이상 연결된 원작만 반환, 총개수+리스트 제공 - 상세 API: 로그인/본인인증 필수, 원작 상세+소속 활성 캐릭터 리스트 반환 --- .../repository/ChatCharacterRepository.kt | 1 + .../chat/original/OriginalWorkRepository.kt | 20 +++++ .../controller/OriginalWorkController.kt | 75 ++++++++++++++++++ .../chat/original/dto/OriginalWorkAppDtos.kt | 77 +++++++++++++++++++ .../service/OriginalWorkQueryService.kt | 45 +++++++++++ .../sodalive/configs/SecurityConfig.kt | 1 + 6 files changed, 219 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index f321f99..9daacec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByName(name: String): ChatCharacter? fun findByIsActiveTrue(pageable: Pageable): Page + fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List /** * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt index 4cac026..97856f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkRepository.kt @@ -31,4 +31,24 @@ interface OriginalWorkRepository : JpaRepository { fun searchNoPaging( @Param("searchTerm") searchTerm: String ): List + + /** + * 앱용 원작 목록 조회 + * - 소프트 삭제 제외 + * - includeAdult=false이면 19금 제외 + * - 활성 캐릭터가 하나라도 연결된 원작만 조회 + */ + @Query( + """ + SELECT ow FROM OriginalWork ow + WHERE ow.isDeleted = false + AND (:includeAdult = true OR ow.isAdult = false) + AND EXISTS ( + SELECT 1 FROM ChatCharacter c + WHERE c.originalWork = ow AND c.isActive = true + ) + ORDER BY ow.createdAt DESC + """ + ) + fun findAllForApp(@Param("includeAdult") includeAdult: Boolean): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt new file mode 100644 index 0000000..4b7dd4b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -0,0 +1,75 @@ +package kr.co.vividnext.sodalive.chat.original.controller + +import kr.co.vividnext.sodalive.chat.character.dto.Character +import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse +import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * 앱용 원작(오리지널 작품) 공개 API + * 1) 목록: 로그인 불필요, 미인증 사용자는 19금 제외, 활성 캐릭터 연결된 원작만 노출 + * 2) 상세: 로그인 + 본인인증 필수 + */ +@RestController +@RequestMapping("/api/chat/original") +class OriginalWorkController( + private val queryService: OriginalWorkQueryService, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + + /** + * 원작 목록 + * - 로그인 불필요 + * - 본인인증하지 않은 경우 19금 제외 + * - 활성 캐릭터가 하나라도 연결된 원작만 노출 + * - 반환: totalCount + [imageUrl, title, contentType] + */ + @GetMapping("/list") + fun list( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + val includeAdult = member?.auth != null + val list = queryService.listForApp(includeAdult) + val content = list.map { OriginalWorkListItemResponse.from(it, imageHost) } + ApiResponse.ok(OriginalWorkListResponse(totalCount = content.size.toLong(), content = content)) + } + + /** + * 원작 상세 + * - 로그인 및 본인인증 필수 + * - 반환: 이미지, 제목, 콘텐츠 타입, 카테고리, 19금 여부, 작품 소개, 원작 링크 + * - 해당 원작의 활성 캐릭터 리스트 [imageUrl, name, description] + */ + @GetMapping("/{id}") + fun detail( + @PathVariable id: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val ow = queryService.getOriginalWork(id) + val characters = queryService.getActiveCharacters(id).map { + val path = it.imagePath ?: "profile/default-profile.png" + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/$path" + ) + } + val response = OriginalWorkDetailResponse.from(ow, imageHost, characters) + ApiResponse.ok(response) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt new file mode 100644 index 0000000..9c38622 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -0,0 +1,77 @@ +package kr.co.vividnext.sodalive.chat.original.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.chat.character.dto.Character +import kr.co.vividnext.sodalive.chat.original.OriginalWork + +/** + * 앱용 원작 목록 아이템 응답 DTO + */ +data class OriginalWorkListItemResponse( + @JsonProperty("id") val id: Long, + @JsonProperty("imageUrl") val imageUrl: String?, + @JsonProperty("title") val title: String, + @JsonProperty("contentType") val contentType: String +) { + companion object { + fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse { + val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { + "$imageHost/${entity.imagePath}" + } else { + entity.imagePath + } + return OriginalWorkListItemResponse( + id = entity.id!!, + imageUrl = fullImage, + title = entity.title, + contentType = entity.contentType + ) + } + } +} + +/** + * 앱용 원작 목록 응답 DTO + */ +data class OriginalWorkListResponse( + @JsonProperty("totalCount") val totalCount: Long, + @JsonProperty("content") val content: List +) + +/** + * 앱용 원작 상세 응답 DTO + */ +data class OriginalWorkDetailResponse( + @JsonProperty("imageUrl") val imageUrl: String?, + @JsonProperty("title") val title: String, + @JsonProperty("contentType") val contentType: String, + @JsonProperty("category") val category: String, + @JsonProperty("isAdult") val isAdult: Boolean, + @JsonProperty("description") val description: String, + @JsonProperty("originalLink") val originalLink: String?, + @JsonProperty("characters") val characters: List +) { + companion object { + fun from( + entity: OriginalWork, + imageHost: String = "", + characters: List + ): OriginalWorkDetailResponse { + val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { + "$imageHost/${entity.imagePath}" + } else { + entity.imagePath + } + return OriginalWorkDetailResponse( + imageUrl = fullImage, + title = entity.title, + contentType = entity.contentType, + category = entity.category, + isAdult = entity.isAdult, + description = entity.description, + originalLink = entity.originalLink, + characters = characters + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt new file mode 100644 index 0000000..1377dab --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.chat.original.service + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 앱 사용자용 원작(오리지널 작품) 조회 서비스 + * - 목록/상세 조회 전용 + */ +@Service +class OriginalWorkQueryService( + private val originalWorkRepository: OriginalWorkRepository, + private val chatCharacterRepository: ChatCharacterRepository +) { + /** + * 앱용 원작 목록 조회 + * @param includeAdult true면 19금 포함, false면 제외 + */ + @Transactional(readOnly = true) + fun listForApp(includeAdult: Boolean): List { + return originalWorkRepository.findAllForApp(includeAdult) + } + + /** + * 원작 상세 조회 (소프트 삭제 제외) + */ + @Transactional(readOnly = true) + fun getOriginalWork(id: Long): OriginalWork { + return originalWorkRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") } + } + + /** + * 지정 원작에 속한 활성 캐릭터 목록 조회 (최신순) + */ + @Transactional(readOnly = true) + fun getActiveCharacters(originalWorkId: Long): List { + return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 39142aa..cc42fbb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -95,6 +95,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() + .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .anyRequest().authenticated() .and() .build() From 41c8d0367d082ac2c6dc29d694438adc44f2bb62 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 00:31:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(original):=20=EC=9B=90=EC=9E=91?= =?UTF-8?q?=EB=B3=84=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../original/AdminOriginalWorkController.kt | 26 ++++++++++++++++++- .../service/AdminOriginalWorkService.kt | 26 ++++++++++++++++++- .../repository/ChatCharacterRepository.kt | 1 + 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt index a6f90a4..71b14c8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/AdminOriginalWorkController.kt @@ -2,11 +2,14 @@ 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 @@ -34,7 +37,7 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/admin/chat/original") @PreAuthorize("hasRole('ADMIN')") class AdminOriginalWorkController( - private val originalWorkService: kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService, + private val originalWorkService: AdminOriginalWorkService, private val s3Uploader: S3Uploader, @Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String, @@ -157,6 +160,27 @@ class AdminOriginalWorkController( 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index 212e7b9..288ddf5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -2,6 +2,7 @@ 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 @@ -85,10 +86,33 @@ class AdminOriginalWorkService( /** 원작 페이징 조회 */ @Transactional(readOnly = true) fun getOriginalWorkPage(page: Int, size: Int): Page { - val pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()) + 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 { + // 원작 존재 및 소프트 삭제 여부 확인 + 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index 9daacec..2269123 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -13,6 +13,7 @@ interface ChatCharacterRepository : JpaRepository { fun findByName(name: String): ChatCharacter? fun findByIsActiveTrue(pageable: Pageable): Page fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List + fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page /** * 2주 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 From b134c28c10b980829a64bf063bf591f31fc8fcde Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 05:18:01 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(original):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원작 데이터 추가 --- .../dto/ChatCharacterDetailResponse.kt | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt index fa006db..32338e6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt @@ -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, val personalities: List, val backgrounds: List, - val memories: List + val memories: List, + 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 +) From 7f3589dcfbebcf0294007d3b194c1977b14cf118 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 15 Sep 2025 05:20:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(original):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐시 키 변경 --- .../sodalive/chat/character/service/ChatCharacterService.kt | 4 ++-- .../chat/character/service/RankingWindowCalculator.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index ea0f5c1..1c23587 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -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 { - 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 { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt index 56dad23..b5ba168 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/RankingWindowCalculator.kt @@ -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)