Compare commits

..

5 Commits

Author SHA1 Message Date
00c617ec2e feat(admin-character): 캐릭터 상세 결과에 characterType 추가 2025-08-12 16:45:25 +09:00
01ef738d31 feat(chat-character): 캐릭터 상세 조회 응답 확장 및 ‘다른 캐릭터’ 추천 추가
- 상세 페이지 정보 강화 및 탐색성 향상을 위해 응답 필드를 확장
- CharacterDetailResponse에 originalTitle, originalLink, characterType, others 추가
- OtherCharacter DTO 추가 (characterId, name, imageUrl, tags)
- 공유 태그 기반으로 현재 캐릭터를 제외한 랜덤 10개 캐릭터 조회 JPA 쿼리 추가
  - ChatCharacterRepository.findRandomBySharedTags(@Query, RAND 정렬, 페이징)
- 서비스 계층에 getOtherCharactersBySharedTags 추가 및 태그 지연 로딩 초기화
- 컨트롤러에서:
  - others 리스트를 조회/매핑하여 응답에 포함
  - originalTitle, originalLink, characterType을 응답에 포함
2025-08-12 03:47:48 +09:00
423cbe7315 feat(chat-character): 캐릭터 상세 조회 응답 스키마 간소화 및 태그 포맷 규칙 적용
- CharacterDetailResponse에서 불필요 필드 제거
  - 제거: age, gender, speechPattern, speechStyle, appearance, memories, relationships, values, hobbies, goals
- 성격(personalities), 배경(backgrounds)을 각각 첫 번째 항목 1개만 반환하도록 변경
  - 단일 객체(Optional)로 응답: CharacterPersonalityResponse?, CharacterBackgroundResponse?
- 태그 포맷 규칙 적용
  - 태그에 # 프리픽스가 없으면 붙이고, 공백으로 연결하여 단일 문자열로 반환
- Controller 로직 정리
  - 불필요 매핑 제거 및 DTO 스키마 변경에 맞춘 변환 로직 반영
2025-08-12 03:16:29 +09:00
afb003c397 feat(chat-character): 원작/원작 링크/캐릭터 유형 추가 및 외부 API 호출 분리
- ChatCharacter 엔티티에 originalTitle, originalLink(Nullable), characterType(Enum) 필드 추가
  - characterType: CLONE | CHARACTER (기본값 CHARACTER)
  - 원작/원작 링크는 빈 문자열 대신 null 허용으로 저장
- Admin DTO(Register/Update)에 originalTitle, originalLink, characterType 필드 추가
- 등록 API에서 외부 API 요청 바디에 3개 필드(originalTitle, originalLink, characterType) 제외 처리
- 수정 API에서 3개 필드만 변경된 경우 외부 API 호출 생략하고 DB만 업데이트
  - hasChanges: 외부 API 대상 필드 변경 여부 판단(3개 필드 제외)
  - hasDbOnlyChanges: 3개 필드만 변경된 경우 처리 분기
- Service 계층에 필드 매핑 및 Enum 파싱 추가
  - createChatCharacter / createChatCharacterWithDetails에 originalTitle/originalLink/characterType 반영
- 이름 중복 검증 로직 유지, isActive=false 비활성화 이름 처리 로직 유지
2025-08-12 02:58:26 +09:00
2dc5a29220 feat(chat-character): 관계 name 필드 추가에 따른 등록/수정/조회 로직 및 DTO 반영
- 관계 스키마를 name, relationShip 구조로 일원화
- Admin/사용자 컨트롤러 조회 응답에서 관계를 객체로 반환하도록 수정
- 등록/수정 요청 DTO에 ChatCharacterRelationshipRequest(name, relationShip) 추가
- 서비스 계층 create/update/add 메소드 시그니처 및 매핑 로직 업데이트
- description 한 줄 소개 사용 전제 하의 관련 사용부 점검(엔티티 컬럼 구성은 기존 유지)
2025-08-12 02:13:46 +09:00
9 changed files with 199 additions and 69 deletions

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequ
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.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.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
@@ -119,6 +120,12 @@ class AdminChatCharacterController(
speechPattern = request.speechPattern, speechPattern = request.speechPattern,
speechStyle = request.speechStyle, speechStyle = request.speechStyle,
appearance = request.appearance, appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
characterType = request.characterType?.let {
runCatching { CharacterType.valueOf(it) }
.getOrDefault(CharacterType.CHARACTER)
} ?: CharacterType.CHARACTER,
tags = request.tags, tags = request.tags,
values = request.values, values = request.values,
hobbies = request.hobbies, hobbies = request.hobbies,
@@ -126,7 +133,7 @@ class AdminChatCharacterController(
memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) }, personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) }, backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships relationships = request.relationships.map { it.name to it.relationShip }
) )
// 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정 // 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정
@@ -152,7 +159,27 @@ class AdminChatCharacterController(
headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요 headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요
headers.contentType = MediaType.APPLICATION_JSON headers.contentType = MediaType.APPLICATION_JSON
val httpEntity = HttpEntity(request, headers) // 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성
val body = mutableMapOf<String, Any>()
body["name"] = request.name
body["systemPrompt"] = request.systemPrompt
body["description"] = request.description
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
if (request.tags.isNotEmpty()) body["tags"] = request.tags
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
if (request.values.isNotEmpty()) body["values"] = request.values
if (request.goals.isNotEmpty()) body["goals"] = request.goals
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
if (request.memories.isNotEmpty()) body["memories"] = request.memories
val httpEntity = HttpEntity(body, headers)
val response = restTemplate.exchange( val response = restTemplate.exchange(
"$apiUrl/api/characters", "$apiUrl/api/characters",
@@ -223,16 +250,22 @@ class AdminChatCharacterController(
val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java) val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java)
// 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 // 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인
val hasChangedData = hasChanges(request) val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외)
// 3. 이미지 있는지 확인 // 3. 이미지 있는지 확인
val hasImage = image != null && !image.isEmpty val hasImage = image != null && !image.isEmpty
if (!hasChangedData && !hasImage) { // 3가지만 변경된 경우(외부 API 변경은 없지만 DB 변경은 있는 경우)를 허용하기 위해 별도 플래그 계산
val hasDbOnlyChanges =
request.originalTitle != null ||
request.originalLink != null ||
request.characterType != null
if (!hasChangedData && !hasImage && !hasDbOnlyChanges) {
throw SodaException("변경된 데이터가 없습니다.") throw SodaException("변경된 데이터가 없습니다.")
} }
// 변경된 데이터가 있으면 외부 API 호출 // 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음)
if (hasChangedData) { if (hasChangedData) {
val chatCharacter = service.findById(request.id) val chatCharacter = service.findById(request.id)
?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}")

View File

@@ -9,6 +9,7 @@ data class ChatCharacterDetailResponse(
val imageUrl: String?, val imageUrl: String?,
val description: String, val description: String,
val systemPrompt: String, val systemPrompt: String,
val characterType: String,
val age: Int?, val age: Int?,
val gender: String?, val gender: String?,
val mbti: String?, val mbti: String?,
@@ -20,7 +21,7 @@ data class ChatCharacterDetailResponse(
val hobbies: List<String>, val hobbies: List<String>,
val values: List<String>, val values: List<String>,
val goals: List<String>, val goals: List<String>,
val relationships: List<String>, 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>
@@ -40,6 +41,7 @@ data class ChatCharacterDetailResponse(
imageUrl = fullImagePath, imageUrl = fullImagePath,
description = chatCharacter.description, description = chatCharacter.description,
systemPrompt = chatCharacter.systemPrompt, systemPrompt = chatCharacter.systemPrompt,
characterType = chatCharacter.characterType.name,
age = chatCharacter.age, age = chatCharacter.age,
gender = chatCharacter.gender, gender = chatCharacter.gender,
mbti = chatCharacter.mbti, mbti = chatCharacter.mbti,
@@ -51,7 +53,7 @@ data class ChatCharacterDetailResponse(
hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby },
values = chatCharacter.valueMappings.map { it.value.value }, values = chatCharacter.valueMappings.map { it.value.value },
goals = chatCharacter.goalMappings.map { it.goal.goal }, goals = chatCharacter.goalMappings.map { it.goal.goal },
relationships = chatCharacter.relationships.map { it.relationShip }, relationships = chatCharacter.relationships.map { RelationshipResponse(it.name, it.relationShip) },
personalities = chatCharacter.personalities.map { personalities = chatCharacter.personalities.map {
PersonalityResponse(it.trait, it.description) PersonalityResponse(it.trait, it.description)
}, },
@@ -81,3 +83,8 @@ data class MemoryResponse(
val content: String, val content: String,
val emotion: String val emotion: String
) )
data class RelationshipResponse(
val name: String,
val relationShip: String
)

View File

@@ -18,6 +18,11 @@ data class ChatCharacterMemoryRequest(
@JsonProperty("emotion") val emotion: String @JsonProperty("emotion") val emotion: String
) )
data class ChatCharacterRelationshipRequest(
@JsonProperty("name") val name: String,
@JsonProperty("relationShip") val relationShip: String
)
data class ChatCharacterRegisterRequest( data class ChatCharacterRegisterRequest(
@JsonProperty("name") val name: String, @JsonProperty("name") val name: String,
@JsonProperty("systemPrompt") val systemPrompt: String, @JsonProperty("systemPrompt") val systemPrompt: String,
@@ -28,11 +33,14 @@ data class ChatCharacterRegisterRequest(
@JsonProperty("speechPattern") val speechPattern: String?, @JsonProperty("speechPattern") val speechPattern: String?,
@JsonProperty("speechStyle") val speechStyle: String?, @JsonProperty("speechStyle") val speechStyle: String?,
@JsonProperty("appearance") val appearance: String?, @JsonProperty("appearance") val appearance: String?,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: 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(),
@JsonProperty("values") val values: List<String> = emptyList(), @JsonProperty("values") val values: List<String> = emptyList(),
@JsonProperty("goals") val goals: List<String> = emptyList(), @JsonProperty("goals") val goals: List<String> = emptyList(),
@JsonProperty("relationships") val relationships: List<String> = emptyList(), @JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest> = emptyList(),
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(), @JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest> = emptyList(),
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(), @JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest> = emptyList(),
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList() @JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest> = emptyList()
@@ -59,12 +67,15 @@ data class ChatCharacterUpdateRequest(
@JsonProperty("speechPattern") val speechPattern: String? = null, @JsonProperty("speechPattern") val speechPattern: String? = null,
@JsonProperty("speechStyle") val speechStyle: String? = null, @JsonProperty("speechStyle") val speechStyle: String? = null,
@JsonProperty("appearance") val appearance: String? = null, @JsonProperty("appearance") val appearance: String? = null,
@JsonProperty("originalTitle") val originalTitle: String? = null,
@JsonProperty("originalLink") val originalLink: 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,
@JsonProperty("hobbies") val hobbies: List<String>? = null, @JsonProperty("hobbies") val hobbies: List<String>? = null,
@JsonProperty("values") val values: List<String>? = null, @JsonProperty("values") val values: List<String>? = null,
@JsonProperty("goals") val goals: List<String>? = null, @JsonProperty("goals") val goals: List<String>? = null,
@JsonProperty("relationships") val relationships: List<String>? = null, @JsonProperty("relationships") val relationships: List<ChatCharacterRelationshipRequest>? = null,
@JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null, @JsonProperty("personalities") val personalities: List<ChatCharacterPersonalityRequest>? = null,
@JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null, @JsonProperty("backgrounds") val backgrounds: List<ChatCharacterBackgroundRequest>? = null,
@JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null @JsonProperty("memories") val memories: List<ChatCharacterMemoryRequest>? = null

View File

@@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.CascadeType import javax.persistence.CascadeType
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType import javax.persistence.FetchType
import javax.persistence.OneToMany import javax.persistence.OneToMany
@@ -14,8 +16,7 @@ class ChatCharacter(
// 캐릭터 이름 (API 키 내에서 유일해야 함) // 캐릭터 이름 (API 키 내에서 유일해야 함)
var name: String, var name: String,
// 캐릭터 설명 // 캐릭터 한 줄 소개
@Column(columnDefinition = "TEXT", nullable = false)
var description: String, var description: String,
// AI 시스템 프롬프트 // AI 시스템 프롬프트
@@ -42,6 +43,19 @@ class ChatCharacter(
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
var appearance: String? = null, var appearance: String? = null,
// 원작 (optional)
@Column(nullable = true)
var originalTitle: String? = null,
// 원작 링크 (optional)
@Column(nullable = true)
var originalLink: String? = null,
// 캐릭터 유형
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var characterType: CharacterType = CharacterType.CHARACTER,
var isActive: Boolean = true var isActive: Boolean = true
) : BaseEntity() { ) : BaseEntity() {
var imagePath: String? = null var imagePath: String? = null
@@ -113,8 +127,13 @@ class ChatCharacter(
} }
// 관계 추가 헬퍼 메소드 // 관계 추가 헬퍼 메소드
fun addRelationship(relationShip: String) { fun addRelationship(name: String, relationShip: String) {
val relationship = ChatCharacterRelationship(relationShip, this) val relationship = ChatCharacterRelationship(name, relationShip, this)
relationships.add(relationship) relationships.add(relationship)
} }
} }
enum class CharacterType {
CLONE,
CHARACTER
}

View File

@@ -12,6 +12,7 @@ import javax.persistence.ManyToOne
@Entity @Entity
class ChatCharacterRelationship( class ChatCharacterRelationship(
var name: String,
val relationShip: String, val relationShip: String,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)

View File

@@ -5,9 +5,9 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterDetailResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterDetailResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterMainResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterMainResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterMemoryResponse
import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
@@ -108,36 +108,39 @@ class ChatCharacterController(
val character = service.getCharacterDetail(characterId) val character = service.getCharacterDetail(characterId)
?: throw SodaException("캐릭터를 찾을 수 없습니다.") ?: throw SodaException("캐릭터를 찾을 수 없습니다.")
// 태그,치관, 취미, 목표 추출 // 태그 가공: # prefix 규칙 적용 후 공백으로 연결
val tags = character.tagMappings.map { it.tag.tag } val tags = character.tagMappings
val values = character.valueMappings.map { it.value.value } .map { it.tag.tag }
val hobbies = character.hobbyMappings.map { it.hobby.hobby } .joinToString(" ") { if (it.startsWith("#")) it else "#$it" }
val goals = character.goalMappings.map { it.goal.goal }
// 메모리, 성격, 배경, 관계 변환 // 성격, 배경: 각각 첫 번째 항목만 선택
val memories = character.memories.map { val personality: CharacterPersonalityResponse? = character.personalities.firstOrNull()?.let {
CharacterMemoryResponse(
title = it.title,
content = it.content,
emotion = it.emotion
)
}
val personalities = character.personalities.map {
CharacterPersonalityResponse( CharacterPersonalityResponse(
trait = it.trait, trait = it.trait,
description = it.description description = it.description
) )
} }
val backgrounds = character.backgrounds.map { val background: CharacterBackgroundResponse? = character.backgrounds.firstOrNull()?.let {
CharacterBackgroundResponse( CharacterBackgroundResponse(
topic = it.topic, topic = it.topic,
description = it.description description = it.description
) )
} }
val relationships = character.relationships.map { it.relationShip } // 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
val others = service.getOtherCharactersBySharedTags(characterId, 10)
.map { other ->
val otherTags = other.tagMappings
.map { it.tag.tag }
.joinToString(" ") { if (it.startsWith("#")) it else "#$it" }
OtherCharacter(
characterId = other.id!!,
name = other.name,
imageUrl = "$imageHost/${other.imagePath ?: "profile/default-profile.png"}",
tags = otherTags
)
}
// 응답 생성 // 응답 생성
ApiResponse.ok( ApiResponse.ok(
@@ -145,21 +148,15 @@ class ChatCharacterController(
characterId = character.id!!, characterId = character.id!!,
name = character.name, name = character.name,
description = character.description, description = character.description,
age = character.age,
gender = character.gender,
mbti = character.mbti, mbti = character.mbti,
speechPattern = character.speechPattern,
speechStyle = character.speechStyle,
appearance = character.appearance,
imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}",
memories = memories, personalities = personality,
personalities = personalities, backgrounds = background,
backgrounds = backgrounds,
relationships = relationships,
tags = tags, tags = tags,
values = values, originalTitle = character.originalTitle,
hobbies = hobbies, originalLink = character.originalLink,
goals = goals characterType = character.characterType,
others = others
) )
) )
} }

View File

@@ -1,30 +1,27 @@
package kr.co.vividnext.sodalive.chat.character.dto package kr.co.vividnext.sodalive.chat.character.dto
import kr.co.vividnext.sodalive.chat.character.CharacterType
data class CharacterDetailResponse( data class CharacterDetailResponse(
val characterId: Long, val characterId: Long,
val name: String, val name: String,
val description: String, val description: String,
val age: Int?,
val gender: String?,
val mbti: String?, val mbti: String?,
val speechPattern: String?,
val speechStyle: String?,
val appearance: String?,
val imageUrl: String, val imageUrl: String,
val memories: List<CharacterMemoryResponse> = emptyList(), val personalities: CharacterPersonalityResponse?,
val personalities: List<CharacterPersonalityResponse> = emptyList(), val backgrounds: CharacterBackgroundResponse?,
val backgrounds: List<CharacterBackgroundResponse> = emptyList(), val tags: String,
val relationships: List<String> = emptyList(), val originalTitle: String?,
val tags: List<String> = emptyList(), val originalLink: String?,
val values: List<String> = emptyList(), val characterType: CharacterType,
val hobbies: List<String> = emptyList(), val others: List<OtherCharacter>
val goals: List<String> = emptyList()
) )
data class CharacterMemoryResponse( data class OtherCharacter(
val title: String, val characterId: Long,
val content: String, val name: String,
val emotion: String val imageUrl: String,
val tags: String
) )
data class CharacterPersonalityResponse( data class CharacterPersonalityResponse(

View File

@@ -66,4 +66,25 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
@Param("member") member: Member, @Param("member") member: Member,
pageable: Pageable pageable: Pageable
): List<ChatCharacter> ): List<ChatCharacter>
/**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/
@Query(
"""
SELECT DISTINCT c FROM ChatCharacter c
JOIN c.tagMappings tm
JOIN tm.tag t
WHERE c.isActive = true
AND c.id <> :characterId
AND t.id IN (
SELECT t2.id FROM ChatCharacterTagMapping tm2 JOIN tm2.tag t2 WHERE tm2.chatCharacter.id = :characterId
)
ORDER BY function('RAND')
"""
)
fun findRandomBySharedTags(
@Param("characterId") characterId: Long,
pageable: Pageable
): List<ChatCharacter>
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.service package kr.co.vividnext.sodalive.chat.character.service
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.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal
import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby
@@ -52,6 +53,20 @@ class ChatCharacterService(
return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit))
} }
/**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/
@Transactional(readOnly = true)
fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List<ChatCharacter> {
val others = chatCharacterRepository.findRandomBySharedTags(
characterId,
PageRequest.of(0, limit)
)
// 태그 초기화 (지연 로딩 문제 방지)
others.forEach { it.tagMappings.size }
return others
}
/** /**
* 태그를 찾거나 생성하여 캐릭터에 연결 * 태그를 찾거나 생성하여 캐릭터에 연결
*/ */
@@ -208,6 +223,9 @@ class ChatCharacterService(
speechPattern: String? = null, speechPattern: String? = null,
speechStyle: String? = null, speechStyle: String? = null,
appearance: String? = null, appearance: String? = null,
originalTitle: String? = null,
originalLink: String? = null,
characterType: CharacterType = CharacterType.CHARACTER,
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
values: List<String> = emptyList(), values: List<String> = emptyList(),
hobbies: List<String> = emptyList(), hobbies: List<String> = emptyList(),
@@ -223,7 +241,10 @@ class ChatCharacterService(
mbti = mbti, mbti = mbti,
speechPattern = speechPattern, speechPattern = speechPattern,
speechStyle = speechStyle, speechStyle = speechStyle,
appearance = appearance appearance = appearance,
originalTitle = originalTitle,
originalLink = originalLink,
characterType = characterType
) )
// 관련 엔티티 연결 // 관련 엔티티 연결
@@ -266,8 +287,8 @@ class ChatCharacterService(
* 캐릭터에 관계 추가 * 캐릭터에 관계 추가
*/ */
@Transactional @Transactional
fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, relationShip: String) { fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, name: String, relationShip: String) {
chatCharacter.addRelationship(relationShip) chatCharacter.addRelationship(name, relationShip)
saveChatCharacter(chatCharacter) saveChatCharacter(chatCharacter)
} }
@@ -286,6 +307,9 @@ class ChatCharacterService(
speechPattern: String? = null, speechPattern: String? = null,
speechStyle: String? = null, speechStyle: String? = null,
appearance: String? = null, appearance: String? = null,
originalTitle: String? = null,
originalLink: String? = null,
characterType: CharacterType = CharacterType.CHARACTER,
tags: List<String> = emptyList(), tags: List<String> = emptyList(),
values: List<String> = emptyList(), values: List<String> = emptyList(),
hobbies: List<String> = emptyList(), hobbies: List<String> = emptyList(),
@@ -293,11 +317,26 @@ class ChatCharacterService(
memories: List<Triple<String, String, String>> = emptyList(), memories: List<Triple<String, String, String>> = emptyList(),
personalities: List<Pair<String, String>> = emptyList(), personalities: List<Pair<String, String>> = emptyList(),
backgrounds: List<Pair<String, String>> = emptyList(), backgrounds: List<Pair<String, String>> = emptyList(),
relationships: List<String> = emptyList() relationships: List<Pair<String, String>> = emptyList()
): ChatCharacter { ): ChatCharacter {
val chatCharacter = createChatCharacter( val chatCharacter = createChatCharacter(
characterUUID, name, description, systemPrompt, age, gender, mbti, characterUUID = characterUUID,
speechPattern, speechStyle, appearance, tags, values, hobbies, goals name = name,
description = description,
systemPrompt = systemPrompt,
age = age,
gender = gender,
mbti = mbti,
speechPattern = speechPattern,
speechStyle = speechStyle,
appearance = appearance,
originalTitle = originalTitle,
originalLink = originalLink,
characterType = characterType,
tags = tags,
values = values,
hobbies = hobbies,
goals = goals
) )
// 추가 정보 설정 // 추가 정보 설정
@@ -313,8 +352,8 @@ class ChatCharacterService(
chatCharacter.addBackground(topic, description) chatCharacter.addBackground(topic, description)
} }
relationships.forEach { relationShip -> relationships.forEach { (name, relationShip) ->
chatCharacter.addRelationship(relationShip) chatCharacter.addRelationship(name, relationShip)
} }
return saveChatCharacter(chatCharacter) return saveChatCharacter(chatCharacter)
@@ -365,6 +404,11 @@ class ChatCharacterService(
request.speechPattern?.let { chatCharacter.speechPattern = it } request.speechPattern?.let { chatCharacter.speechPattern = it }
request.speechStyle?.let { chatCharacter.speechStyle = it } request.speechStyle?.let { chatCharacter.speechStyle = it }
request.appearance?.let { chatCharacter.appearance = it } request.appearance?.let { chatCharacter.appearance = it }
request.originalTitle?.let { chatCharacter.originalTitle = it }
request.originalLink?.let { chatCharacter.originalLink = it }
request.characterType?.let {
runCatching { CharacterType.valueOf(it) }.getOrNull()?.let { ct -> chatCharacter.characterType = ct }
}
// request에서 변경된 데이터만 업데이트 // request에서 변경된 데이터만 업데이트
if (request.tags != null) { if (request.tags != null) {
@@ -412,7 +456,7 @@ class ChatCharacterService(
if (request.relationships != null) { if (request.relationships != null) {
chatCharacter.relationships.clear() chatCharacter.relationships.clear()
request.relationships.forEach { relationship -> request.relationships.forEach { relationship ->
chatCharacter.addRelationship(relationship) chatCharacter.addRelationship(relationship.name, relationship.relationShip)
} }
} }