Merge pull request 'test' (#341) from test into main

Reviewed-on: #341
This commit is contained in:
klaus 2025-09-14 20:33:50 +00:00
commit ce120a6d5d
17 changed files with 840 additions and 15 deletions

View File

@ -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,
@ -68,6 +70,19 @@ class AdminChatCharacterController(
ApiResponse.ok(response) ApiResponse.ok(response)
} }
/**
* 캐릭터 검색(관리자)
* - 이름/설명/MBTI/태그 기준 부분 검색, 활성 캐릭터만 대상
* - 페이징 제거: 전체 목록 반환
*/
@GetMapping("/search")
fun searchCharacters(
@RequestParam("searchTerm") searchTerm: String
) = run {
val list = adminService.searchCharactersAll(searchTerm, imageHost)
ApiResponse.ok(list)
}
/** /**
* 캐릭터 상세 정보 조회 API * 캐릭터 상세 정보 조회 API
* *
@ -137,6 +152,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 +267,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 +307,12 @@ class AdminChatCharacterController(
request = request request = request
) )
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리
originalWorkService.assignOneCharacter(request.originalWorkId, request.id)
}
ApiResponse.ok(null) ApiResponse.ok(null)
} }

View File

@ -2,6 +2,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
/**
* 관리자 캐릭터 상세 응답 DTO
* - 원작이 연결되어 있으면 원작 요약 정보(originalWork) 함께 반환한다.
*/
data class ChatCharacterDetailResponse( data class ChatCharacterDetailResponse(
val id: Long, val id: Long,
val characterUUID: String, val characterUUID: String,
@ -24,7 +28,8 @@ data class ChatCharacterDetailResponse(
val relationships: List<RelationshipResponse>, val relationships: List<RelationshipResponse>,
val personalities: List<PersonalityResponse>, val personalities: List<PersonalityResponse>,
val backgrounds: List<BackgroundResponse>, val backgrounds: List<BackgroundResponse>,
val memories: List<MemoryResponse> val memories: List<MemoryResponse>,
val originalWork: OriginalWorkBriefResponse? // 추가: 원작 요약 정보
) { ) {
companion object { companion object {
fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse { fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse {
@ -34,6 +39,20 @@ data class ChatCharacterDetailResponse(
chatCharacter.imagePath ?: "" 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( return ChatCharacterDetailResponse(
id = chatCharacter.id!!, id = chatCharacter.id!!,
characterUUID = chatCharacter.characterUUID, characterUUID = chatCharacter.characterUUID,
@ -71,7 +90,8 @@ data class ChatCharacterDetailResponse(
}, },
memories = chatCharacter.memories.map { memories = chatCharacter.memories.map {
MemoryResponse(it.title, it.content, it.emotion) MemoryResponse(it.title, it.content, it.emotion)
} },
originalWork = originalWorkBrief
) )
} }
} }
@ -101,3 +121,12 @@ data class RelationshipResponse(
val relationshipType: String, val relationshipType: String,
val currentStatus: String val currentStatus: String
) )
/**
* 원작 요약 응답 DTO(관리자 캐릭터 상세용)
*/
data class OriginalWorkBriefResponse(
val id: Long,
val imageUrl: String?,
val title: String
)

View File

@ -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,

View File

@ -65,12 +65,7 @@ class AdminChatCharacterService(
} }
/** /**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 페이징 (기존 사용처 호환용)
*
* @param searchTerm 검색어
* @param pageable 페이징 정보
* @param imageHost 이미지 호스트 URL
* @return 검색된 캐릭터 목록 (페이징)
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun searchCharacters( fun searchCharacters(
@ -81,4 +76,20 @@ class AdminChatCharacterService(
val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable)
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
} }
/**
* 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) - 무페이징
*
* @param searchTerm 검색어
* @param imageHost 이미지 호스트 URL
* @return 검색된 캐릭터 목록 (전체)
*/
@Transactional(readOnly = true)
fun searchCharactersAll(
searchTerm: String,
imageHost: String = ""
): List<ChatCharacterSearchResponse> {
val characters = chatCharacterRepository.searchCharactersNoPaging(searchTerm)
return characters.map { ChatCharacterSearchResponse.from(it, imageHost) }
}
} }

View File

@ -0,0 +1,199 @@
package kr.co.vividnext.sodalive.admin.chat.original
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkAssignCharactersRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkPageResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
/**
* 원작(오리지널 작품) 관리자 API
* - 원작 등록/수정/삭제
* - 원작과 캐릭터 연결(배정) 해제
*/
@RestController
@RequestMapping("/admin/chat/original")
@PreAuthorize("hasRole('ADMIN')")
class AdminOriginalWorkController(
private val originalWorkService: AdminOriginalWorkService,
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}")
private val s3Bucket: String,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
/**
* 원작 등록
* - 이미지 파일과 JSON 요청을 멀티파트로 받는다.
*/
@PostMapping("/register")
fun register(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkRegisterRequest::class.java)
// 서비스 계층을 통해 원작을 생성
val saved = originalWorkService.createOriginalWork(request)
// 이미지 업로드 후 이미지 경로 업데이트
val imagePath = uploadImage(saved.id!!, image)
originalWorkService.updateOriginalWorkImage(saved.id!!, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 수정
* - 이미지가 있으면 교체, 없으면 유지
*/
@PutMapping("/update")
fun update(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") requestString: String
) = run {
val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, OriginalWorkUpdateRequest::class.java)
// 이미지가 전달된 경우 먼저 업로드하여 경로를 생성
val imagePath = if (image != null && !image.isEmpty) {
uploadImage(request.id, image)
} else {
null
}
originalWorkService.updateOriginalWork(request, imagePath)
ApiResponse.ok(null)
}
/**
* 원작 삭제
*/
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long) = run {
originalWorkService.deleteOriginalWork(id)
ApiResponse.ok(null)
}
/**
* 원작 목록(페이징)
*/
@GetMapping("/list")
fun list(
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getOriginalWorkPage(page, size)
val content = pageRes.content.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkPageResponse(totalCount = pageRes.totalElements, content = content))
}
/**
* 원작 검색(관리자)
* - 제목/콘텐츠타입/카테고리 기준 부분 검색, 소프트 삭제 제외
* - 페이징 제거: 전체 목록 반환
*/
@GetMapping("/search")
fun search(
@RequestParam("searchTerm") searchTerm: String
) = run {
val list = originalWorkService.searchOriginalWorksAll(searchTerm)
val content = list.map { OriginalWorkResponse.from(it, imageHost) }
ApiResponse.ok(content)
}
/**
* 원작 상세
*/
@GetMapping("/{id}")
fun detail(@PathVariable id: Long) = run {
ApiResponse.ok(OriginalWorkResponse.from(originalWorkService.getOriginalWork(id), imageHost))
}
/**
* 원작에 기존 캐릭터들을 배정
* - 캐릭터는 하나의 원작에만 속하므로, 해당 캐릭터들의 originalWork를 원작으로 설정
*/
@PostMapping("/{id}/assign-characters")
fun assignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.assignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 원작에서 캐릭터들 해제
* - 캐릭터들의 originalWork를 null로 설정
*/
@PostMapping("/{id}/unassign-characters")
fun unassignCharacters(
@PathVariable id: Long,
@RequestBody body: OriginalWorkAssignCharactersRequest
) = run {
originalWorkService.unassignCharacters(id, body.characterIds)
ApiResponse.ok(null)
}
/**
* 관리자용: 지정 원작에 속한 캐릭터 목록 페이징 조회
* - 활성 캐릭터만 포함
* - 응답 항목: 캐릭터 이미지(URL), 이름
*/
@GetMapping("/{id}/characters")
fun listCharactersOfOriginal(
@PathVariable id: Long,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
) = run {
val pageRes = originalWorkService.getCharactersOfOriginalWorkPage(id, page, size)
val content = pageRes.content.map { ChatCharacterSearchResponse.from(it, imageHost) }
ApiResponse.ok(
ChatCharacterSearchListPageResponse(
totalCount = pageRes.totalElements,
content = content
)
)
}
/** 이미지 업로드 공통 처리 */
private fun uploadImage(originalWorkId: Long, image: MultipartFile): String {
try {
val metadata = ObjectMetadata()
metadata.contentLength = image.size
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "originals/$originalWorkId/${generateFileName(prefix = "original")}",
metadata = metadata
)
} catch (e: Exception) {
throw SodaException("이미지 저장에 실패했습니다: ${e.message}")
}
}
}

View File

@ -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>
)

View File

@ -0,0 +1,155 @@
package kr.co.vividnext.sodalive.admin.chat.original.service
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkRegisterRequest
import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkUpdateRequest
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 원작(오리지널 작품) 관련 관리자 서비스
* - 컨트롤러와 레포지토리 사이의 서비스 계층으로 DB 접근을 캡슐화한다.
*/
@Service
class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository
) {
/** 원작 등록 (중복 제목 방지 포함) */
@Transactional
fun createOriginalWork(request: OriginalWorkRegisterRequest): OriginalWork {
originalWorkRepository.findByTitleAndIsDeletedFalse(request.title)?.let {
throw SodaException("동일한 제목의 원작이 이미 존재합니다: ${request.title}")
}
val entity = OriginalWork(
title = request.title,
contentType = request.contentType,
category = request.category,
isAdult = request.isAdult,
description = request.description,
originalLink = request.originalLink
)
return originalWorkRepository.save(entity)
}
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
@Transactional
fun updateOriginalWork(request: OriginalWorkUpdateRequest, imagePath: String? = null): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(request.id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
request.title?.let { ow.title = it }
request.contentType?.let { ow.contentType = it }
request.category?.let { ow.category = it }
request.isAdult?.let { ow.isAdult = it }
request.description?.let { ow.description = it }
request.originalLink?.let { ow.originalLink = it }
if (imagePath != null) {
ow.imagePath = imagePath
}
return originalWorkRepository.save(ow)
}
/** 원작 이미지 경로만 별도 갱신 */
@Transactional
fun updateOriginalWorkImage(originalWorkId: Long, imagePath: String): OriginalWork {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
ow.imagePath = imagePath
return originalWorkRepository.save(ow)
}
/** 원작 삭제 (소프트 삭제) */
@Transactional
fun deleteOriginalWork(id: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다: $id") }
ow.isDeleted = true
originalWorkRepository.save(ow)
}
/** 원작 상세 조회 (소프트 삭제 제외) */
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
}
/** 원작 페이징 조회 */
@Transactional(readOnly = true)
fun getOriginalWorkPage(page: Int, size: Int): Page<OriginalWork> {
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return originalWorkRepository.findByIsDeletedFalse(pageable)
}
/** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */
@Transactional(readOnly = true)
fun getCharactersOfOriginalWorkPage(originalWorkId: Long, page: Int, size: Int): Page<ChatCharacter> {
// 원작 존재 및 소프트 삭제 여부 확인
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val safePage = if (page < 0) 0 else page
val safeSize = when {
size <= 0 -> 20
size > 100 -> 100
else -> size
}
val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending())
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrue(originalWorkId, pageable)
}
/** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */
@Transactional(readOnly = true)
fun searchOriginalWorksAll(searchTerm: String): List<OriginalWork> {
return originalWorkRepository.searchNoPaging(searchTerm)
}
/** 원작에 기존 캐릭터들을 배정 */
@Transactional
fun assignCharacters(originalWorkId: Long, characterIds: List<Long>) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = ow }
chatCharacterRepository.saveAll(characters)
}
/** 원작에서 캐릭터들 해제 */
@Transactional
fun unassignCharacters(originalWorkId: Long, characterIds: List<Long>) {
// 원작 존재 확인 (소프트 삭제 제외)
originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
if (characterIds.isEmpty()) return
val characters = chatCharacterRepository.findByIdInAndIsActiveTrue(characterIds)
characters.forEach { it.originalWork = null }
chatCharacterRepository.saveAll(characters)
}
/** 단일 캐릭터를 지정 원작에 배정 */
@Transactional
fun assignOneCharacter(originalWorkId: Long, characterId: Long) {
val ow = originalWorkRepository.findByIdAndIsDeletedFalse(originalWorkId)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
val character = chatCharacterRepository.findById(characterId)
.orElseThrow { SodaException("해당 캐릭터를 찾을 수 없습니다") }
character.originalWork = ow
chatCharacterRepository.save(character)
}
}

View File

@ -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)

View File

@ -12,6 +12,8 @@ import org.springframework.stereotype.Repository
interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> { interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findByName(name: String): ChatCharacter? fun findByName(name: String): ChatCharacter?
fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter> fun findByIsActiveTrue(pageable: Pageable): Page<ChatCharacter>
fun findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId: Long): List<ChatCharacter>
fun findByOriginalWorkIdAndIsActiveTrue(originalWorkId: Long, pageable: Pageable): Page<ChatCharacter>
/** /**
* 2 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회 * 2 이내(파라미터 since 이상) 활성 캐릭터 페이징 조회
@ -31,7 +33,7 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long fun countByIsActiveTrueAndCreatedAtGreaterThanEqual(since: java.time.LocalDateTime): Long
/** /**
* 이름, 설명, MBTI, 태그로 캐릭터 검색 * 이름, 설명, MBTI, 태그로 캐릭터 검색 - 페이징
*/ */
@Query( @Query(
""" """
@ -52,6 +54,28 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable pageable: Pageable
): Page<ChatCharacter> ): Page<ChatCharacter>
/**
* 이름, 설명, MBTI, 태그로 캐릭터 검색 - 무페이징 전체 목록
*/
@Query(
"""
SELECT DISTINCT c FROM ChatCharacter c
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true AND
(
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
ORDER BY c.createdAt DESC
"""
)
fun searchCharactersNoPaging(
@Param("searchTerm") searchTerm: String
): List<ChatCharacter>
/** /**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/ */

View File

@ -45,10 +45,10 @@ class ChatCharacterService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Cacheable( @Cacheable(
cacheNames = ["popularCharacters_24h"], cacheNames = ["popularCharacters_24h"],
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-chat-character').cacheKey" key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey"
) )
fun getPopularCharacters(limit: Long = 20): List<Character> { fun getPopularCharacters(limit: Long = 20): List<Character> {
val window = RankingWindowCalculator.now("popular-chat-character") val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit)
val list = loadCharactersInOrder(topIds) val list = loadCharactersInOrder(topIds)
return list.map { return list.map {

View File

@ -20,7 +20,7 @@ object RankingWindowCalculator {
private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC private const val BOUNDARY_HOUR = 20 // 20:00:00 UTC
@JvmStatic @JvmStatic
fun now(prefix: String = "popular-chat-character"): RankingWindow { fun now(prefix: String = "popular-character"): RankingWindow {
val now = ZonedDateTime.now(ZONE) val now = ZonedDateTime.now(ZONE)
val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE) val todayBoundary = now.toLocalDate().atTime(BOUNDARY_HOUR, 0, 0).atZone(ZONE)

View File

@ -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
}

View File

@ -0,0 +1,54 @@
package kr.co.vividnext.sodalive.chat.original
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.util.Optional
@Repository
interface OriginalWorkRepository : JpaRepository<OriginalWork, Long> {
fun findByTitleAndIsDeletedFalse(title: String): OriginalWork?
fun findByIdAndIsDeletedFalse(id: Long): Optional<OriginalWork>
fun findByIsDeletedFalse(pageable: Pageable): Page<OriginalWork>
/**
* 제목/콘텐츠타입/카테고리 기준 부분 검색 (소프트 삭제 제외) - 무페이징 전체 목록
*/
@Query(
"""
SELECT ow FROM OriginalWork ow
WHERE ow.isDeleted = false AND (
LOWER(ow.title) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(ow.contentType) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(ow.category) LIKE LOWER(CONCAT('%', :searchTerm, '%'))
)
ORDER BY ow.createdAt DESC
"""
)
fun searchNoPaging(
@Param("searchTerm") searchTerm: String
): List<OriginalWork>
/**
* 앱용 원작 목록 조회
* - 소프트 삭제 제외
* - includeAdult=false이면 19 제외
* - 활성 캐릭터가 하나라도 연결된 원작만 조회
*/
@Query(
"""
SELECT ow FROM OriginalWork ow
WHERE ow.isDeleted = false
AND (:includeAdult = true OR ow.isAdult = false)
AND EXISTS (
SELECT 1 FROM ChatCharacter c
WHERE c.originalWork = ow AND c.isActive = true
)
ORDER BY ow.createdAt DESC
"""
)
fun findAllForApp(@Param("includeAdult") includeAdult: Boolean): List<OriginalWork>
}

View File

@ -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)
}
}

View File

@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.original.OriginalWork
/**
* 앱용 원작 목록 아이템 응답 DTO
*/
data class OriginalWorkListItemResponse(
@JsonProperty("id") val id: Long,
@JsonProperty("imageUrl") val imageUrl: String?,
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String
) {
companion object {
fun from(entity: OriginalWork, imageHost: String = ""): OriginalWorkListItemResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkListItemResponse(
id = entity.id!!,
imageUrl = fullImage,
title = entity.title,
contentType = entity.contentType
)
}
}
}
/**
* 앱용 원작 목록 응답 DTO
*/
data class OriginalWorkListResponse(
@JsonProperty("totalCount") val totalCount: Long,
@JsonProperty("content") val content: List<OriginalWorkListItemResponse>
)
/**
* 앱용 원작 상세 응답 DTO
*/
data class OriginalWorkDetailResponse(
@JsonProperty("imageUrl") val imageUrl: String?,
@JsonProperty("title") val title: String,
@JsonProperty("contentType") val contentType: String,
@JsonProperty("category") val category: String,
@JsonProperty("isAdult") val isAdult: Boolean,
@JsonProperty("description") val description: String,
@JsonProperty("originalLink") val originalLink: String?,
@JsonProperty("characters") val characters: List<Character>
) {
companion object {
fun from(
entity: OriginalWork,
imageHost: String = "",
characters: List<Character>
): OriginalWorkDetailResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}"
} else {
entity.imagePath
}
return OriginalWorkDetailResponse(
imageUrl = fullImage,
title = entity.title,
contentType = entity.contentType,
category = entity.category,
isAdult = entity.isAdult,
description = entity.description,
originalLink = entity.originalLink,
characters = characters
)
}
}
}

View File

@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.common.SodaException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 사용자용 원작(오리지널 작품) 조회 서비스
* - 목록/상세 조회 전용
*/
@Service
class OriginalWorkQueryService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository
) {
/**
* 앱용 원작 목록 조회
* @param includeAdult true면 19 포함, false면 제외
*/
@Transactional(readOnly = true)
fun listForApp(includeAdult: Boolean): List<OriginalWork> {
return originalWorkRepository.findAllForApp(includeAdult)
}
/**
* 원작 상세 조회 (소프트 삭제 제외)
*/
@Transactional(readOnly = true)
fun getOriginalWork(id: Long): OriginalWork {
return originalWorkRepository.findByIdAndIsDeletedFalse(id)
.orElseThrow { SodaException("해당 원작을 찾을 수 없습니다") }
}
/**
* 지정 원작에 속한 활성 캐릭터 목록 조회 (최신순)
*/
@Transactional(readOnly = true)
fun getActiveCharacters(originalWorkId: Long): List<ChatCharacter> {
return chatCharacterRepository.findByOriginalWorkIdAndIsActiveTrueOrderByCreatedAtDesc(originalWorkId)
}
}

View File

@ -95,6 +95,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
.and() .and()
.build() .build()