From 73038222cc1c3f139983ae12ab4921a025c6567e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 5 Aug 2025 16:41:53 +0900 Subject: [PATCH 001/119] =?UTF-8?q?feat:=20.junie/,=20.kiro/=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=9D=B4=ED=95=98=20=ED=8C=8C=EC=9D=BC=EB=93=A4=20?= =?UTF-8?q?git=EC=97=90=20=ED=8F=AC=ED=95=A8=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 67ee418..67439e8 100644 --- a/.gitignore +++ b/.gitignore @@ -323,4 +323,7 @@ gradle-app.setting ### Gradle Patch ### **/build/ +.kiro/ +.junie + # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,intellij,java,kotlin,macos,windows,eclipse,gradle From 689f9fe48f6e14f82e90138bb280533100a91cdf Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 Aug 2025 17:42:48 +0900 Subject: [PATCH 002/119] =?UTF-8?q?feat(chat):=20ChatCharacter=EC=99=80=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B0=84=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatCharacter와 Memory, Personality, Background, Relationship 간 1:N 관계 설정 Tag, Value, Hobby, Goal 엔티티의 중복 방지 및 관계 매핑 구현 관계 설정을 위한 서비스 및 리포지토리 클래스 추가 --- .../sodalive/chat/character/ChatCharacter.kt | 115 ++++++++ .../chat/character/ChatCharacterBackground.kt | 24 ++ .../chat/character/ChatCharacterGoal.kt | 22 ++ .../character/ChatCharacterGoalMapping.kt | 22 ++ .../chat/character/ChatCharacterHobby.kt | 22 ++ .../character/ChatCharacterHobbyMapping.kt | 22 ++ .../chat/character/ChatCharacterMemory.kt | 27 ++ .../character/ChatCharacterPersonality.kt | 24 ++ .../character/ChatCharacterRelationship.kt | 20 ++ .../chat/character/ChatCharacterTag.kt | 22 ++ .../chat/character/ChatCharacterTagMapping.kt | 22 ++ .../chat/character/ChatCharacterValue.kt | 22 ++ .../character/ChatCharacterValueMapping.kt | 22 ++ .../sodalive/chat/character/README.md | 118 ++++++++ .../repository/ChatCharacterGoalRepository.kt | 10 + .../ChatCharacterHobbyRepository.kt | 10 + .../repository/ChatCharacterRepository.kt | 11 + .../repository/ChatCharacterTagRepository.kt | 10 + .../ChatCharacterValueRepository.kt | 10 + .../character/service/ChatCharacterService.kt | 263 ++++++++++++++++++ 20 files changed, 818 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoal.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoalMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobby.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobbyMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTag.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTagMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValue.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValueMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/README.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterGoalRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterHobbyRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterTagRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterValueRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt 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 new file mode 100644 index 0000000..70a253c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -0,0 +1,115 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.OneToMany + +@Entity +class ChatCharacter( + val characterUUID: String, + + // 캐릭터 이름 (API 키 내에서 유일해야 함) + val name: String, + + // 캐릭터 설명 + val description: String, + + // AI 시스템 프롬프트 + val systemPrompt: String, + + // 나이 + val age: Int? = null, + + // 성별 + val gender: String? = null, + + // mbti + val mbti: String? = null, + + // 말투 패턴 설명 + val speechPattern: String? = null, + + // 대화 스타일 + val speechStyle: String? = null, + + // 외모 설명 + val appearance: String? = null, + + val isActive: Boolean = true +) : BaseEntity() { + var imagePath: String? = null + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var memories: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var personalities: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var backgrounds: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var relationships: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var tagMappings: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var valueMappings: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var hobbyMappings: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + var goalMappings: MutableList = mutableListOf() + + // 태그 추가 헬퍼 메소드 + fun addTag(tag: ChatCharacterTag) { + val mapping = ChatCharacterTagMapping(this, tag) + tagMappings.add(mapping) + } + + // 가치관 추가 헬퍼 메소드 + fun addValue(value: ChatCharacterValue) { + val mapping = ChatCharacterValueMapping(this, value) + valueMappings.add(mapping) + } + + // 취미 추가 헬퍼 메소드 + fun addHobby(hobby: ChatCharacterHobby) { + val mapping = ChatCharacterHobbyMapping(this, hobby) + hobbyMappings.add(mapping) + } + + // 목표 추가 헬퍼 메소드 + fun addGoal(goal: ChatCharacterGoal) { + val mapping = ChatCharacterGoalMapping(this, goal) + goalMappings.add(mapping) + } + + // 기억 추가 헬퍼 메소드 + fun addMemory(title: String, content: String, emotion: String) { + val memory = ChatCharacterMemory(title, content, emotion, this) + memories.add(memory) + } + + // 성격 추가 헬퍼 메소드 + fun addPersonality(trait: String, description: String) { + val personality = ChatCharacterPersonality(trait, description, this) + personalities.add(personality) + } + + // 배경 추가 헬퍼 메소드 + fun addBackground(topic: String, description: String) { + val background = ChatCharacterBackground(topic, description, this) + backgrounds.add(background) + } + + // 관계 추가 헬퍼 메소드 + fun addRelationship(relationShip: String) { + val relationship = ChatCharacterRelationship(relationShip, this) + relationships.add(relationship) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt new file mode 100644 index 0000000..4c8b4d6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 캐릭터 배경 정보 + */ + +@Entity +class ChatCharacterBackground( + // 배경 주제 + val topic: String, + + // 배경 설명 + val description: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoal.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoal.kt new file mode 100644 index 0000000..4f6d198 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoal.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 목표 + */ + +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["goal"])]) +class ChatCharacterGoal( + @Column(nullable = false) + val goal: String +) : BaseEntity() { + @OneToMany(mappedBy = "goal") + var goalMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoalMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoalMapping.kt new file mode 100644 index 0000000..0d6b070 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterGoalMapping.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * ChatCharacter와 ChatCharacterGoal 간의 매핑 엔티티 + * ChatCharacterGoal의 중복을 방지하기 위한 매핑 테이블 + */ +@Entity +class ChatCharacterGoalMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "goal_id") + val goal: ChatCharacterGoal +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobby.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobby.kt new file mode 100644 index 0000000..df894ff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobby.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 취미 + */ + +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["hobby"])]) +class ChatCharacterHobby( + @Column(nullable = false) + val hobby: String +) : BaseEntity() { + @OneToMany(mappedBy = "hobby") + var hobbyMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobbyMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobbyMapping.kt new file mode 100644 index 0000000..1f5a1b9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterHobbyMapping.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * ChatCharacter와 ChatCharacterHobby 간의 매핑 엔티티 + * ChatCharacterHobby의 중복을 방지하기 위한 매핑 테이블 + */ +@Entity +class ChatCharacterHobbyMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hobby_id") + val hobby: ChatCharacterHobby +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt new file mode 100644 index 0000000..b39cbd4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 캐릭터 기억 + */ + +@Entity +class ChatCharacterMemory( + // 기억 제목 + val title: String, + + // 기억 내용 + val content: String, + + // 감정 + val emotion: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt new file mode 100644 index 0000000..d6bd7b3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 캐릭터 성격 특성 + */ + +@Entity +class ChatCharacterPersonality( + // 성격 특성 + val trait: String, + + // 성격 특성 설명 + val description: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt new file mode 100644 index 0000000..ba3932c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 캐릭터 관계 + */ + +@Entity +class ChatCharacterRelationship( + val relationShip: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTag.kt new file mode 100644 index 0000000..d00f09f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTag.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 태그 + */ + +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])]) +class ChatCharacterTag( + @Column(nullable = false) + val tag: String +) : BaseEntity() { + @OneToMany(mappedBy = "tag") + var tagMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTagMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTagMapping.kt new file mode 100644 index 0000000..95d09fc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterTagMapping.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * ChatCharacter와 ChatCharacterTag 간의 매핑 엔티티 + * ChatCharacterTag의 중복을 방지하기 위한 매핑 테이블 + */ +@Entity +class ChatCharacterTagMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + val tag: ChatCharacterTag +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValue.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValue.kt new file mode 100644 index 0000000..70fa863 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValue.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 가치관 + */ + +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["value"])]) +class ChatCharacterValue( + @Column(nullable = false) + val value: String +) : BaseEntity() { + @OneToMany(mappedBy = "value") + var valueMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValueMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValueMapping.kt new file mode 100644 index 0000000..9a730b0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterValueMapping.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * ChatCharacter와 ChatCharacterValue 간의 매핑 엔티티 + * ChatCharacterValue의 중복을 방지하기 위한 매핑 테이블 + */ +@Entity +class ChatCharacterValueMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_character_id") + val chatCharacter: ChatCharacter, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "value_id") + val value: ChatCharacterValue +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/README.md b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/README.md new file mode 100644 index 0000000..14b1671 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/README.md @@ -0,0 +1,118 @@ +# ChatCharacter 엔티티 관계 구현 + +## 개요 + +이 구현은 ChatCharacter와 다른 엔티티들 간의 1:N 관계를 설정합니다. ChatCharacter가 저장될 때 관련 엔티티들도 함께 저장되며, Tag, Value, Hobby, Goal은 중복을 허용하지 않고 기존 내용과 동일한 내용이 들어오면 관계만 맺습니다. + +## 엔티티 구조 + +### 주요 엔티티 + +- **ChatCharacter**: 메인 엔티티로, 캐릭터의 기본 정보를 저장합니다. +- **ChatCharacterMemory**: 캐릭터의 기억 정보를 저장합니다. +- **ChatCharacterPersonality**: 캐릭터의 성격 특성을 저장합니다. +- **ChatCharacterBackground**: 캐릭터의 배경 정보를 저장합니다. +- **ChatCharacterRelationship**: 캐릭터의 관계 정보를 저장합니다. + +### 중복 방지를 위한 엔티티 + +- **ChatCharacterTag**: 캐릭터 태그 정보를 저장합니다. 태그 이름은 유일합니다. +- **ChatCharacterValue**: 캐릭터 가치관 정보를 저장합니다. 가치관 이름은 유일합니다. +- **ChatCharacterHobby**: 캐릭터 취미 정보를 저장합니다. 취미 이름은 유일합니다. +- **ChatCharacterGoal**: 캐릭터 목표 정보를 저장합니다. 목표 이름은 유일합니다. + +### 매핑 엔티티 + +- **ChatCharacterTagMapping**: ChatCharacter와 ChatCharacterTag 간의 관계를 맺습니다. +- **ChatCharacterValueMapping**: ChatCharacter와 ChatCharacterValue 간의 관계를 맺습니다. +- **ChatCharacterHobbyMapping**: ChatCharacter와 ChatCharacterHobby 간의 관계를 맺습니다. +- **ChatCharacterGoalMapping**: ChatCharacter와 ChatCharacterGoal 간의 관계를 맺습니다. + +## 관계 설정 + +- ChatCharacter와 Memory, Personality, Background, Relationship은 1:N 관계입니다. +- ChatCharacter와 Tag, Value, Hobby, Goal은 매핑 엔티티를 통한 N:M 관계입니다. +- 모든 관계는 ChatCharacter가 저장될 때 함께 저장됩니다(CascadeType.ALL). + +## 서비스 기능 + +ChatCharacterService는 다음과 같은 기능을 제공합니다: + +1. 캐릭터 생성 및 저장 +2. 캐릭터 조회 (UUID, 이름, 전체 목록) +3. 캐릭터에 태그, 가치관, 취미, 목표 추가 +4. 캐릭터에 기억, 성격 특성, 배경 정보, 관계 추가 +5. 모든 정보를 포함한 캐릭터 생성 + +## 사용 예시 + +```kotlin +// 캐릭터 생성 +val chatCharacter = chatCharacterService.createChatCharacter( + characterUUID = "uuid-123", + name = "AI 어시스턴트", + description = "친절한 AI 어시스턴트", + systemPrompt = "당신은 친절한 AI 어시스턴트입니다." +) + +// 태그 추가 +chatCharacterService.addTagToCharacter(chatCharacter, "친절함") +chatCharacterService.addTagToCharacter(chatCharacter, "도움") + +// 가치관 추가 +chatCharacterService.addValueToCharacter(chatCharacter, "정직") + +// 취미 추가 +chatCharacterService.addHobbyToCharacter(chatCharacter, "독서") + +// 목표 추가 +chatCharacterService.addGoalToCharacter(chatCharacter, "사용자 만족") + +// 기억 추가 +chatCharacterService.addMemoryToChatCharacter( + chatCharacter, + "첫 만남", + "사용자와의 첫 대화", + "기쁨" +) + +// 성격 특성 추가 +chatCharacterService.addPersonalityToChatCharacter( + chatCharacter, + "친절함", + "항상 친절하게 대응합니다." +) + +// 배경 정보 추가 +chatCharacterService.addBackgroundToChatCharacter( + chatCharacter, + "생성 배경", + "사용자를 돕기 위해 만들어졌습니다." +) + +// 관계 추가 +chatCharacterService.addRelationshipToChatCharacter( + chatCharacter, + "사용자와의 관계: 도우미" +) + +// 모든 정보를 포함한 캐릭터 생성 +val completeCharacter = chatCharacterService.createChatCharacterWithDetails( + characterUUID = "uuid-456", + name = "종합 AI", + description = "모든 정보를 가진 AI", + systemPrompt = "당신은 모든 정보를 가진 AI입니다.", + tags = listOf("종합", "지식"), + values = listOf("정확성", "유용성"), + hobbies = listOf("학습", "정보 수집"), + goals = listOf("정확한 정보 제공"), + memories = listOf(Triple("학습 시작", "처음 학습을 시작했습니다.", "호기심")), + personalities = listOf(Pair("분석적", "정보를 분석적으로 처리합니다.")), + backgrounds = listOf(Pair("개발 목적", "정보 제공을 위해 개발되었습니다.")), + relationships = listOf("사용자와의 관계: 정보 제공자") +) +``` + +## 중복 방지 메커니즘 + +Tag, Value, Hobby, Goal 엔티티는 각각 고유한 필드(tag, value, hobby, goal)에 대해 유니크 제약 조건을 가지고 있습니다. 서비스 레이어에서는 이미 존재하는 엔티티를 찾아 재사용하거나, 존재하지 않는 경우 새로 생성합니다. 이를 통해 중복을 방지하고 관계만 맺을 수 있습니다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterGoalRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterGoalRepository.kt new file mode 100644 index 0000000..621ddc1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterGoalRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChatCharacterGoalRepository : JpaRepository { + fun findByGoal(goal: String): ChatCharacterGoal? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterHobbyRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterHobbyRepository.kt new file mode 100644 index 0000000..e572153 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterHobbyRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChatCharacterHobbyRepository : JpaRepository { + fun findByHobby(hobby: String): ChatCharacterHobby? +} 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 new file mode 100644 index 0000000..f9547dd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChatCharacterRepository : JpaRepository { + fun findByCharacterUUID(characterUUID: String): ChatCharacter? + fun findByName(name: String): ChatCharacter? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterTagRepository.kt new file mode 100644 index 0000000..fe53163 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterTagRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChatCharacterTagRepository : JpaRepository { + fun findByTag(tag: String): ChatCharacterTag? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterValueRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterValueRepository.kt new file mode 100644 index 0000000..9c0aa12 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterValueRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ChatCharacterValueRepository : JpaRepository { + fun findByValue(value: String): ChatCharacterValue? +} 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 new file mode 100644 index 0000000..3ffad40 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -0,0 +1,263 @@ +package kr.co.vividnext.sodalive.chat.character.service + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal +import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby +import kr.co.vividnext.sodalive.chat.character.ChatCharacterTag +import kr.co.vividnext.sodalive.chat.character.ChatCharacterValue +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatCharacterService( + private val chatCharacterRepository: ChatCharacterRepository, + private val tagRepository: ChatCharacterTagRepository, + private val valueRepository: ChatCharacterValueRepository, + private val hobbyRepository: ChatCharacterHobbyRepository, + private val goalRepository: ChatCharacterGoalRepository +) { + + /** + * 태그를 찾거나 생성하여 캐릭터에 연결 + */ + @Transactional + fun addTagToCharacter(chatCharacter: ChatCharacter, tagName: String) { + val tag = tagRepository.findByTag(tagName) ?: ChatCharacterTag(tagName) + if (tag.id == null) { + tagRepository.save(tag) + } + chatCharacter.addTag(tag) + } + + /** + * 가치관을 찾거나 생성하여 캐릭터에 연결 + */ + @Transactional + fun addValueToCharacter(chatCharacter: ChatCharacter, valueName: String) { + val value = valueRepository.findByValue(valueName) ?: ChatCharacterValue(valueName) + if (value.id == null) { + valueRepository.save(value) + } + chatCharacter.addValue(value) + } + + /** + * 취미를 찾거나 생성하여 캐릭터에 연결 + */ + @Transactional + fun addHobbyToCharacter(chatCharacter: ChatCharacter, hobbyName: String) { + val hobby = hobbyRepository.findByHobby(hobbyName) ?: ChatCharacterHobby(hobbyName) + if (hobby.id == null) { + hobbyRepository.save(hobby) + } + chatCharacter.addHobby(hobby) + } + + /** + * 목표를 찾거나 생성하여 캐릭터에 연결 + */ + @Transactional + fun addGoalToCharacter(chatCharacter: ChatCharacter, goalName: String) { + val goal = goalRepository.findByGoal(goalName) ?: ChatCharacterGoal(goalName) + if (goal.id == null) { + goalRepository.save(goal) + } + chatCharacter.addGoal(goal) + } + + /** + * 여러 태그를 한번에 캐릭터에 연결 + */ + @Transactional + fun addTagsToCharacter(chatCharacter: ChatCharacter, tags: List) { + tags.forEach { addTagToCharacter(chatCharacter, it) } + } + + /** + * 여러 가치관을 한번에 캐릭터에 연결 + */ + @Transactional + fun addValuesToCharacter(chatCharacter: ChatCharacter, values: List) { + values.forEach { addValueToCharacter(chatCharacter, it) } + } + + /** + * 여러 취미를 한번에 캐릭터에 연결 + */ + @Transactional + fun addHobbiesToCharacter(chatCharacter: ChatCharacter, hobbies: List) { + hobbies.forEach { addHobbyToCharacter(chatCharacter, it) } + } + + /** + * 여러 목표를 한번에 캐릭터에 연결 + */ + @Transactional + fun addGoalsToCharacter(chatCharacter: ChatCharacter, goals: List) { + goals.forEach { addGoalToCharacter(chatCharacter, it) } + } + + /** + * 캐릭터 저장 + */ + @Transactional + fun saveChatCharacter(chatCharacter: ChatCharacter): ChatCharacter { + return chatCharacterRepository.save(chatCharacter) + } + + /** + * UUID로 캐릭터 조회 + */ + @Transactional(readOnly = true) + fun findByCharacterUUID(characterUUID: String): ChatCharacter? { + return chatCharacterRepository.findByCharacterUUID(characterUUID) + } + + /** + * 이름으로 캐릭터 조회 + */ + @Transactional(readOnly = true) + fun findByName(name: String): ChatCharacter? { + return chatCharacterRepository.findByName(name) + } + + /** + * 모든 캐릭터 조회 + */ + @Transactional(readOnly = true) + fun findAll(): List { + return chatCharacterRepository.findAll() + } + + /** + * 캐릭터 생성 및 관련 엔티티 연결 + */ + @Transactional + fun createChatCharacter( + characterUUID: String, + name: String, + description: String, + systemPrompt: String, + age: Int? = null, + gender: String? = null, + mbti: String? = null, + speechPattern: String? = null, + speechStyle: String? = null, + appearance: String? = null, + tags: List = emptyList(), + values: List = emptyList(), + hobbies: List = emptyList(), + goals: List = emptyList() + ): ChatCharacter { + val chatCharacter = ChatCharacter( + characterUUID = characterUUID, + name = name, + description = description, + systemPrompt = systemPrompt, + age = age, + gender = gender, + mbti = mbti, + speechPattern = speechPattern, + speechStyle = speechStyle, + appearance = appearance + ) + + // 관련 엔티티 연결 + addTagsToCharacter(chatCharacter, tags) + addValuesToCharacter(chatCharacter, values) + addHobbiesToCharacter(chatCharacter, hobbies) + addGoalsToCharacter(chatCharacter, goals) + + return saveChatCharacter(chatCharacter) + } + + /** + * 캐릭터에 기억 추가 + */ + @Transactional + fun addMemoryToChatCharacter(chatCharacter: ChatCharacter, title: String, content: String, emotion: String) { + chatCharacter.addMemory(title, content, emotion) + saveChatCharacter(chatCharacter) + } + + /** + * 캐릭터에 성격 특성 추가 + */ + @Transactional + fun addPersonalityToChatCharacter(chatCharacter: ChatCharacter, trait: String, description: String) { + chatCharacter.addPersonality(trait, description) + saveChatCharacter(chatCharacter) + } + + /** + * 캐릭터에 배경 정보 추가 + */ + @Transactional + fun addBackgroundToChatCharacter(chatCharacter: ChatCharacter, topic: String, description: String) { + chatCharacter.addBackground(topic, description) + saveChatCharacter(chatCharacter) + } + + /** + * 캐릭터에 관계 추가 + */ + @Transactional + fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, relationShip: String) { + chatCharacter.addRelationship(relationShip) + saveChatCharacter(chatCharacter) + } + + /** + * 캐릭터 생성 시 기본 정보와 함께 추가 정보도 설정 + */ + @Transactional + fun createChatCharacterWithDetails( + characterUUID: String, + name: String, + description: String, + systemPrompt: String, + age: Int? = null, + gender: String? = null, + mbti: String? = null, + speechPattern: String? = null, + speechStyle: String? = null, + appearance: String? = null, + tags: List = emptyList(), + values: List = emptyList(), + hobbies: List = emptyList(), + goals: List = emptyList(), + memories: List> = emptyList(), + personalities: List> = emptyList(), + backgrounds: List> = emptyList(), + relationships: List = emptyList() + ): ChatCharacter { + val chatCharacter = createChatCharacter( + characterUUID, name, description, systemPrompt, age, gender, mbti, + speechPattern, speechStyle, appearance, tags, values, hobbies, goals + ) + + // 추가 정보 설정 + memories.forEach { (title, content, emotion) -> + chatCharacter.addMemory(title, content, emotion) + } + + personalities.forEach { (trait, description) -> + chatCharacter.addPersonality(trait, description) + } + + backgrounds.forEach { (topic, description) -> + chatCharacter.addBackground(topic, description) + } + + relationships.forEach { relationShip -> + chatCharacter.addRelationship(relationShip) + } + + return saveChatCharacter(chatCharacter) + } +} From 3b4239972680195ca2d297f583c50d34a6ef30c4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 Aug 2025 18:44:56 +0900 Subject: [PATCH 003/119] =?UTF-8?q?feat:=20255=EC=9E=90=20=EB=84=98?= =?UTF-8?q?=EC=96=B4=EA=B0=80=EC=95=BC=20=ED=95=98=EB=8A=94=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20columnDefinition=20=3D=20"TEXT"=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt | 5 +++++ .../sodalive/chat/character/ChatCharacterBackground.kt | 2 ++ .../vividnext/sodalive/chat/character/ChatCharacterMemory.kt | 2 ++ 3 files changed, 9 insertions(+) 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 70a253c..42fd8f0 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 @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.common.BaseEntity import javax.persistence.CascadeType +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.OneToMany @@ -14,9 +15,11 @@ class ChatCharacter( val name: String, // 캐릭터 설명 + @Column(columnDefinition = "TEXT", nullable = false) val description: String, // AI 시스템 프롬프트 + @Column(columnDefinition = "TEXT", nullable = false) val systemPrompt: String, // 나이 @@ -29,12 +32,14 @@ class ChatCharacter( val mbti: String? = null, // 말투 패턴 설명 + @Column(columnDefinition = "TEXT") val speechPattern: String? = null, // 대화 스타일 val speechStyle: String? = null, // 외모 설명 + @Column(columnDefinition = "TEXT") val appearance: String? = null, val isActive: Boolean = true diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt index 4c8b4d6..a3297fa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -16,6 +17,7 @@ class ChatCharacterBackground( val topic: String, // 배경 설명 + @Column(columnDefinition = "TEXT", nullable = false) val description: String, @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt index b39cbd4..9ef9380 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -16,6 +17,7 @@ class ChatCharacterMemory( val title: String, // 기억 내용 + @Column(columnDefinition = "TEXT", nullable = false) val content: String, // 감정 From de6642b67576afed73cc75edc69c37ad1434ad60 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 Aug 2025 20:51:01 +0900 Subject: [PATCH 004/119] =?UTF-8?q?git=20commit=20-m=20"feat(chat):=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=93=B1=EB=A1=9D=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부 API 호출 및 응답 처리 구현 - 이미지 파일 S3 업로드 기능 추가 - Multipart 요청 처리 지원" --- .../character/AdminChatCharacterController.kt | 147 ++++++++++++++++++ .../chat/character/dto/ChatCharacterDto.kt | 48 ++++++ src/main/resources/application.yml | 4 + 3 files changed, 199 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.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 new file mode 100644 index 0000000..bea7f13 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -0,0 +1,147 @@ +package kr.co.vividnext.sodalive.admin.chat.character + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +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.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.retry.annotation.Backoff +import org.springframework.retry.annotation.Retryable +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestTemplate +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/admin/chat/character") +@PreAuthorize("hasRole('ADMIN')") +class AdminChatCharacterController( + private val service: ChatCharacterService, + private val s3Uploader: S3Uploader, + + @Value("\${weraser.api-key}") + private val apiKey: String, + + @Value("\${weraser.api-url}") + private val apiUrl: String, + + @Value("\${cloud.aws.s3.bucket}") + private val s3Bucket: String +) { + @PostMapping("/register") + @Retryable( + value = [Exception::class], + maxAttempts = 3, + backoff = Backoff(delay = 1000) + ) + fun registerCharacter( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + // JSON 문자열을 ChatCharacterRegisterRequest 객체로 변환 + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java) + + // 1. 외부 API 호출 + val characterUUID = callExternalApi(request) + + // 2. ChatCharacter 저장 + val chatCharacter = service.createChatCharacterWithDetails( + characterUUID = characterUUID, + name = request.name, + description = request.description, + systemPrompt = request.systemPrompt, + age = request.age?.toIntOrNull(), + gender = request.gender, + mbti = request.mbti, + speechPattern = request.speechPattern, + speechStyle = request.speechStyle, + appearance = request.appearance, + tags = request.tags, + values = request.values, + hobbies = request.hobbies, + goals = request.goals, + memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, + personalities = request.personalities.map { Pair(it.trait, it.description) }, + backgrounds = request.backgrounds.map { Pair(it.topic, it.description) }, + relationships = request.relationships + ) + + // 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정 + val imagePath = saveImage( + characterId = chatCharacter.id!!, + image = image + ) + chatCharacter.imagePath = imagePath + service.saveChatCharacter(chatCharacter) + + ApiResponse.ok(null) + } + + private fun callExternalApi(request: ChatCharacterRegisterRequest): String { + try { + val factory = SimpleClientHttpRequestFactory() + factory.setConnectTimeout(20000) // 20초 + factory.setReadTimeout(20000) // 20초 + + val restTemplate = RestTemplate(factory) + + val headers = HttpHeaders() + headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요 + headers.contentType = MediaType.APPLICATION_JSON + + val httpEntity = HttpEntity(request, headers) + + val response = restTemplate.exchange( + "$apiUrl/api/characters", + HttpMethod.POST, + httpEntity, + String::class.java + ) + + // 응답 파싱 + val objectMapper = ObjectMapper() + val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java) + + // success가 false이면 throw + if (!apiResponse.success) { + throw SodaException(apiResponse.message ?: "등록에 실패했습니다. 다시 시도해 주세요.") + } + + // success가 true이면 data.id 반환 + return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.") + } catch (_: Exception) { + throw SodaException("등록에 실패했습니다. 다시 시도해 주세요.") + } + } + + private fun saveImage(characterId: Long, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + + // S3에 이미지 업로드 + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "characters/$characterId/${generateFileName(prefix = "character")}", + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } +} 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 new file mode 100644 index 0000000..299bdfd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.admin.chat.character.dto + +data class ChatCharacterPersonalityRequest( + val trait: String, + val description: String +) + +data class ChatCharacterBackgroundRequest( + val topic: String, + val description: String +) + +data class ChatCharacterMemoryRequest( + val title: String, + val content: String, + val emotion: String +) + +data class ChatCharacterRegisterRequest( + val name: String, + val systemPrompt: String, + val description: String, + val age: String?, + val gender: String?, + val mbti: String?, + val speechPattern: String?, + val speechStyle: String?, + val appearance: String?, + val isActive: Boolean = true, + val tags: List = emptyList(), + val hobbies: List = emptyList(), + val values: List = emptyList(), + val goals: List = emptyList(), + val relationships: List = emptyList(), + val personalities: List = emptyList(), + val backgrounds: List = emptyList(), + val memories: List = emptyList() +) + +data class ExternalApiResponse( + val success: Boolean, + val data: ExternalApiData? = null, + val message: String? = null +) + +data class ExternalApiData( + val id: String +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fbc0fd4..0c81b0a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,10 @@ logging: util: EC2MetadataUtils: error +weraser: + apiUrl: {$WERASER_API_URL} + apiKey: {$WERASER_API_KEY} + bootpay: applicationId: ${BOOTPAY_APPLICATION_ID} privateKey: ${BOOTPAY_PRIVATE_KEY} From 5132a6b9fa13018aa25731a6290e9f3057bdb713 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 Aug 2025 21:59:16 +0900 Subject: [PATCH 005/119] =?UTF-8?q?feat(character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatCharacterUpdateRequest 클래스 추가 (모든 필드 nullable) - ChatCharacter 엔티티의 필드를 var로 변경하여 수정 가능하게 함 - 이미지 포함/제외 수정 API를 하나로 통합 - 변경된 데이터만 업데이트하도록 구현 - isActive가 false인 경우 특별 처리 추가 --- .../character/AdminChatCharacterController.kt | 160 ++++++++++++++++++ .../chat/character/dto/ChatCharacterDto.kt | 22 +++ .../sodalive/chat/character/ChatCharacter.kt | 20 +-- .../character/service/ChatCharacterService.kt | 105 ++++++++++++ 4 files changed, 297 insertions(+), 10 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 bea7f13..070719e 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 @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character import com.amazonaws.services.s3.model.ObjectMetadata import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest +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.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService @@ -19,6 +20,7 @@ import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController @@ -144,4 +146,162 @@ class AdminChatCharacterController( throw SodaException("이미지 저장에 실패했습니다: ${e.message}") } } + + /** + * 캐릭터 수정 API + * 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환 + * 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 + * 3. 이미지 있는지 확인 + * 4. 2, 3번 중 하나라도 해당 하면 계속 진행 + * 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.') + * + * @param image 캐릭터 이미지 (선택적) + * @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값 + * @return ApiResponse 객체 + * @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우 + */ + @PutMapping("/update") + @Retryable( + value = [Exception::class], + maxAttempts = 3, + backoff = Backoff(delay = 1000) + ) + fun updateCharacter( + @RequestPart(value = "image", required = false) image: MultipartFile?, + @RequestPart("request") requestString: String + ) = run { + // 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환 + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java) + + // 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 + val hasChangedData = hasChanges(request) + + // 3. 이미지 있는지 확인 + val hasImage = image != null && !image.isEmpty + + if (!hasChangedData && !hasImage) { + throw SodaException("변경된 데이터가 없습니다.") + } + + // 변경된 데이터가 있으면 외부 API 호출 + if (hasChangedData) { + val chatCharacter = service.findById(request.id) + ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") + callExternalApiForUpdate(chatCharacter.characterUUID, request) + } + + // 이미지 경로 변수 초기화 + // 이미지가 있으면 이미지 저장 + val imagePath = if (hasImage) { + saveImage( + characterId = request.id, + image = image!! + ) + } else { + null + } + + // 엔티티 수정 + service.updateChatCharacterWithDetails( + imagePath = imagePath, + request = request + ) + + ApiResponse.ok(null) + } + + /** + * 요청에 변경된 데이터가 있는지 확인 + * id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단 + * + * @param request 수정 요청 데이터 + * @return 변경된 데이터가 있으면 true, 없으면 false + */ + private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean { + return request.systemPrompt != null || + request.description != null || + request.age != null || + request.gender != null || + request.mbti != null || + request.speechPattern != null || + request.speechStyle != null || + request.appearance != null || + request.isActive != null || + request.tags != null || + request.hobbies != null || + request.values != null || + request.goals != null || + request.relationships != null || + request.personalities != null || + request.backgrounds != null || + request.memories != null || + request.name != null + } + + /** + * 외부 API 호출 - 수정 API + * 변경된 데이터만 요청에 포함 + * + * @param characterUUID 캐릭터 UUID + * @param request 수정 요청 데이터 + */ + private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) { + try { + val factory = SimpleClientHttpRequestFactory() + factory.setConnectTimeout(20000) // 20초 + factory.setReadTimeout(20000) // 20초 + + val restTemplate = RestTemplate(factory) + + val headers = HttpHeaders() + headers.set("x-api-key", apiKey) + headers.contentType = MediaType.APPLICATION_JSON + + // 변경된 데이터만 포함하는 맵 생성 + val updateData = mutableMapOf() + + if (request.isActive != null && !request.isActive) { + updateData["name"] = "inactive_${request.name}" + } else { + request.name?.let { updateData["name"] = it } + request.systemPrompt?.let { updateData["systemPrompt"] = it } + request.description?.let { updateData["description"] = it } + request.age?.let { updateData["age"] = it } + request.gender?.let { updateData["gender"] = it } + request.mbti?.let { updateData["mbti"] = it } + request.speechPattern?.let { updateData["speechPattern"] = it } + request.speechStyle?.let { updateData["speechStyle"] = it } + request.appearance?.let { updateData["appearance"] = it } + request.tags?.let { updateData["tags"] = it } + request.hobbies?.let { updateData["hobbies"] = it } + request.values?.let { updateData["values"] = it } + request.goals?.let { updateData["goals"] = it } + request.relationships?.let { updateData["relationships"] = it } + request.personalities?.let { updateData["personalities"] = it } + request.backgrounds?.let { updateData["backgrounds"] = it } + request.memories?.let { updateData["memories"] = it } + } + + val httpEntity = HttpEntity(updateData, headers) + + val response = restTemplate.exchange( + "$apiUrl/api/characters/$characterUUID", + HttpMethod.PUT, + httpEntity, + String::class.java + ) + + // 응답 파싱 + val objectMapper = ObjectMapper() + val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java) + + // success가 false이면 throw + if (!apiResponse.success) { + throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.") + } + } catch (_: Exception) { + throw SodaException("수정에 실패했습니다. 다시 시도해 주세요.") + } + } } 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 299bdfd..94cf606 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 @@ -46,3 +46,25 @@ data class ExternalApiResponse( data class ExternalApiData( val id: String ) + +data class ChatCharacterUpdateRequest( + val id: Long, + val name: String? = null, + val systemPrompt: String? = null, + val description: String? = null, + val age: String? = null, + val gender: String? = null, + val mbti: String? = null, + val speechPattern: String? = null, + val speechStyle: String? = null, + val appearance: String? = null, + val isActive: Boolean? = null, + val tags: List? = null, + val hobbies: List? = null, + val values: List? = null, + val goals: List? = null, + val relationships: List? = null, + val personalities: List? = null, + val backgrounds: List? = null, + val memories: List? = null +) 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 42fd8f0..a5a3e86 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 @@ -12,37 +12,37 @@ class ChatCharacter( val characterUUID: String, // 캐릭터 이름 (API 키 내에서 유일해야 함) - val name: String, + var name: String, // 캐릭터 설명 @Column(columnDefinition = "TEXT", nullable = false) - val description: String, + var description: String, // AI 시스템 프롬프트 @Column(columnDefinition = "TEXT", nullable = false) - val systemPrompt: String, + var systemPrompt: String, // 나이 - val age: Int? = null, + var age: Int? = null, // 성별 - val gender: String? = null, + var gender: String? = null, // mbti - val mbti: String? = null, + var mbti: String? = null, // 말투 패턴 설명 @Column(columnDefinition = "TEXT") - val speechPattern: String? = null, + var speechPattern: String? = null, // 대화 스타일 - val speechStyle: String? = null, + var speechStyle: String? = null, // 외모 설명 @Column(columnDefinition = "TEXT") - val appearance: String? = null, + var appearance: String? = null, - val isActive: Boolean = true + var isActive: Boolean = true ) : BaseEntity() { var imagePath: String? = null 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 3ffad40..af72b69 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.chat.character.service +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby @@ -134,6 +135,14 @@ class ChatCharacterService( return chatCharacterRepository.findAll() } + /** + * ID로 캐릭터 조회 + */ + @Transactional(readOnly = true) + fun findById(id: Long): ChatCharacter? { + return chatCharacterRepository.findById(id).orElse(null) + } + /** * 캐릭터 생성 및 관련 엔티티 연결 */ @@ -260,4 +269,100 @@ class ChatCharacterService( return saveChatCharacter(chatCharacter) } + + /** + * 캐릭터 수정 시 기본 정보와 함께 추가 정보도 설정 + * 이름은 변경할 수 없으므로 이름은 변경하지 않음 + * 변경된 데이터만 업데이트 + * + * @param imagePath 이미지 경로 (null이면 이미지 변경 없음) + * @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능) + * @return 수정된 ChatCharacter 객체 + * @throws SodaException 캐릭터를 찾을 수 없는 경우 + */ + @Transactional + fun updateChatCharacterWithDetails( + imagePath: String? = null, + request: ChatCharacterUpdateRequest + ): ChatCharacter { + // 캐릭터 조회 + val chatCharacter = findById(request.id) + ?: throw kr.co.vividnext.sodalive.common.SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") + + // isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다. + if (request.isActive != null && !request.isActive) { + chatCharacter.isActive = false + chatCharacter.name = "inactive_${chatCharacter.name}" + + return saveChatCharacter(chatCharacter) + } + + // 이미지 경로가 있으면 설정 + if (imagePath != null) { + chatCharacter.imagePath = imagePath + } + + // 기본 필드 업데이트 - 변경된 데이터만 업데이트 + request.name?.let { chatCharacter.name = it } + request.systemPrompt?.let { chatCharacter.systemPrompt = it } + request.description?.let { chatCharacter.description = it } + request.age?.toIntOrNull()?.let { chatCharacter.age = it } + request.gender?.let { chatCharacter.gender = it } + request.mbti?.let { chatCharacter.mbti = it } + request.speechPattern?.let { chatCharacter.speechPattern = it } + request.speechStyle?.let { chatCharacter.speechStyle = it } + request.appearance?.let { chatCharacter.appearance = it } + + // request에서 변경된 데이터만 업데이트 + if (request.tags != null) { + chatCharacter.tagMappings.clear() + addTagsToCharacter(chatCharacter, request.tags) + } + + if (request.values != null) { + chatCharacter.valueMappings.clear() + addValuesToCharacter(chatCharacter, request.values) + } + + if (request.hobbies != null) { + chatCharacter.hobbyMappings.clear() + addHobbiesToCharacter(chatCharacter, request.hobbies) + } + + if (request.goals != null) { + chatCharacter.goalMappings.clear() + addGoalsToCharacter(chatCharacter, request.goals) + } + + // 추가 정보 설정 - 변경된 데이터만 업데이트 + if (request.memories != null) { + chatCharacter.memories.clear() + request.memories.forEach { memory -> + chatCharacter.addMemory(memory.title, memory.content, memory.emotion) + } + } + + if (request.personalities != null) { + chatCharacter.personalities.clear() + request.personalities.forEach { personality -> + chatCharacter.addPersonality(personality.trait, personality.description) + } + } + + if (request.backgrounds != null) { + chatCharacter.backgrounds.clear() + request.backgrounds.forEach { background -> + chatCharacter.addBackground(background.topic, background.description) + } + } + + if (request.relationships != null) { + chatCharacter.relationships.clear() + request.relationships.forEach { relationship -> + chatCharacter.addRelationship(relationship) + } + } + + return saveChatCharacter(chatCharacter) + } } From 45b6c8db9683a6be33d360d7e1101210057d3a91 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 Aug 2025 22:19:52 +0900 Subject: [PATCH 006/119] =?UTF-8?q?git=20commit=20-m=20"fix(chat):=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름 중복 검사 로직 추가 --- .../character/AdminChatCharacterController.kt | 21 +++++++++++++++++-- .../character/service/ChatCharacterService.kt | 5 ++++- 2 files changed, 23 insertions(+), 3 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 070719e..806d665 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 @@ -57,6 +57,12 @@ class AdminChatCharacterController( val objectMapper = ObjectMapper() val request = objectMapper.readValue(requestString, ChatCharacterRegisterRequest::class.java) + // 외부 API 호출 전 DB에 동일한 이름이 있는지 조회 + val existingCharacter = service.findByName(request.name) + if (existingCharacter != null) { + throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}") + } + // 1. 외부 API 호출 val characterUUID = callExternalApi(request) @@ -188,6 +194,15 @@ class AdminChatCharacterController( if (hasChangedData) { val chatCharacter = service.findById(request.id) ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") + + // 이름이 수정된 경우 DB에 동일한 이름이 있는지 확인 + if (request.name != null && request.name != chatCharacter.name) { + val existingCharacter = service.findByName(request.name) + if (existingCharacter != null) { + throw SodaException("동일한 이름은 등록이 불가능합니다: ${request.name}") + } + } + callExternalApiForUpdate(chatCharacter.characterUUID, request) } @@ -261,8 +276,11 @@ class AdminChatCharacterController( // 변경된 데이터만 포함하는 맵 생성 val updateData = mutableMapOf() + // isActive = false인 경우 처리 if (request.isActive != null && !request.isActive) { - updateData["name"] = "inactive_${request.name}" + val inactiveName = "inactive_${request.name}" + val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "") + updateData["name"] = inactiveName + randomSuffix } else { request.name?.let { updateData["name"] = it } request.systemPrompt?.let { updateData["systemPrompt"] = it } @@ -284,7 +302,6 @@ class AdminChatCharacterController( } val httpEntity = HttpEntity(updateData, headers) - val response = restTemplate.exchange( "$apiUrl/api/characters/$characterUUID", HttpMethod.PUT, 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 af72b69..ab4254a 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 @@ -292,7 +292,10 @@ class ChatCharacterService( // isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다. if (request.isActive != null && !request.isActive) { chatCharacter.isActive = false - chatCharacter.name = "inactive_${chatCharacter.name}" + + val inactiveName = "inactive_${request.name}" + val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "") + chatCharacter.name = inactiveName + randomSuffix return saveChatCharacter(chatCharacter) } From 618f80fddca1be3c1cf02da55e517f0a90067902 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 11:57:53 +0900 Subject: [PATCH 007/119] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. isActive가 true인 캐릭터만 조회하는 기능 구현 2. 페이징 처리 구현 (기본 20개 조회) 3. 필요한 데이터 포함 (id, 캐릭터명, 프로필 이미지, 설명, 성별, 나이, MBTI, 태그, 성격, 말투, 등록일, 수정일) --- .../character/AdminChatCharacterController.kt | 27 +++++++- .../dto/ChatCharacterListResponse.kt | 62 +++++++++++++++++++ .../service/AdminChatCharacterService.kt | 46 ++++++++++++++ .../repository/ChatCharacterRepository.kt | 3 + 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.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 806d665..7f5bf84 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 @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest 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.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.common.ApiResponse @@ -19,9 +20,11 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping 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.client.RestTemplate @@ -32,6 +35,7 @@ import org.springframework.web.multipart.MultipartFile @PreAuthorize("hasRole('ADMIN')") class AdminChatCharacterController( private val service: ChatCharacterService, + private val adminService: AdminChatCharacterService, private val s3Uploader: S3Uploader, @Value("\${weraser.api-key}") @@ -41,8 +45,29 @@ class AdminChatCharacterController( private val apiUrl: String, @Value("\${cloud.aws.s3.bucket}") - private val s3Bucket: String + private val s3Bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { + /** + * 활성화된 캐릭터 목록 조회 API + * + * @param page 페이지 번호 (0부터 시작, 기본값 0) + * @param size 페이지 크기 (기본값 20) + * @return 페이징된 캐릭터 목록 + */ + @GetMapping("/list") + fun getCharacterList( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = run { + val pageable = adminService.createDefaultPageRequest(page, size) + val response = adminService.getActiveChatCharacters(pageable, imageHost) + + ApiResponse.ok(response) + } + @PostMapping("/register") @Retryable( value = [Exception::class], diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt new file mode 100644 index 0000000..bf0977c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterListResponse.kt @@ -0,0 +1,62 @@ +package kr.co.vividnext.sodalive.admin.chat.character.dto + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +data class ChatCharacterListResponse( + val id: Long, + val name: String, + val imageUrl: String?, + val description: String, + val gender: String?, + val age: Int?, + val mbti: String?, + val speechStyle: String?, + val speechPattern: String?, + val tags: List, + val createdAt: String?, + val updatedAt: String? +) { + companion object { + private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val seoulZoneId = ZoneId.of("Asia/Seoul") + + fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterListResponse { + val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) { + "$imageHost/${chatCharacter.imagePath}" + } else { + chatCharacter.imagePath + } + + // UTC에서 Asia/Seoul로 시간대 변환 및 문자열 포맷팅 + val createdAtStr = chatCharacter.createdAt?.atZone(ZoneId.of("UTC")) + ?.withZoneSameInstant(seoulZoneId) + ?.format(formatter) + + val updatedAtStr = chatCharacter.updatedAt?.atZone(ZoneId.of("UTC")) + ?.withZoneSameInstant(seoulZoneId) + ?.format(formatter) + + return ChatCharacterListResponse( + id = chatCharacter.id!!, + name = chatCharacter.name, + imageUrl = fullImagePath, + description = chatCharacter.description, + gender = chatCharacter.gender, + age = chatCharacter.age, + mbti = chatCharacter.mbti, + speechStyle = chatCharacter.speechStyle, + speechPattern = chatCharacter.speechPattern, + tags = chatCharacter.tagMappings.map { it.tag.tag }, + createdAt = createdAtStr, + updatedAt = updatedAtStr + ) + } + } +} + +data class ChatCharacterListPageResponse( + val totalCount: Long, + val content: List +) 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 new file mode 100644 index 0000000..d9cd176 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/service/AdminChatCharacterService.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.admin.chat.character.service + +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AdminChatCharacterService( + private val chatCharacterRepository: ChatCharacterRepository +) { + /** + * 활성화된 캐릭터 목록을 페이징하여 조회 + * + * @param pageable 페이징 정보 + * @return 페이징된 캐릭터 목록 + */ + @Transactional(readOnly = true) + fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse { + // isActive가 true인 캐릭터만 조회 + val page = chatCharacterRepository.findByIsActiveTrue(pageable) + + // 페이지 정보 생성 + val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) } + + return ChatCharacterListPageResponse( + totalCount = page.totalElements, + content = content + ) + } + + /** + * 기본 페이지 요청 생성 + * + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지 크기 + * @return 페이지 요청 객체 + */ + fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) + } +} 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 f9547dd..4d39bcb 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 @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.chat.character.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -8,4 +10,5 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? + fun findByIsActiveTrue(pageable: Pageable): Page } From 6340ed27cfd4a429dfb5a26435a60baa52eefa5a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 12:01:34 +0900 Subject: [PATCH 008/119] =?UTF-8?q?fix(chat):=20ChatCharacter=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EC=9D=98=20isActive=20=EC=86=8D=EC=84=B1=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/service/AdminChatCharacterService.kt | 2 +- .../chat/character/repository/ChatCharacterRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 d9cd176..25433c2 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 @@ -22,7 +22,7 @@ class AdminChatCharacterService( @Transactional(readOnly = true) fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse { // isActive가 true인 캐릭터만 조회 - val page = chatCharacterRepository.findByIsActiveTrue(pageable) + val page = chatCharacterRepository.findByActiveTrue(pageable) // 페이지 정보 생성 val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) } 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 4d39bcb..f5ab3dc 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 @@ -10,5 +10,5 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? - fun findByIsActiveTrue(pageable: Pageable): Page + fun findByActiveTrue(pageable: Pageable): Page } From 2335050834c225117d8f618039fa8b93f469748d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 12:30:19 +0900 Subject: [PATCH 009/119] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=83=81=EC=84=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character/AdminChatCharacterController.kt | 16 ++++ .../dto/ChatCharacterDetailResponse.kt | 83 +++++++++++++++++++ .../service/AdminChatCharacterService.kt | 18 ++++ 3 files changed, 117 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.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 7f5bf84..9c05dca 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 @@ -21,6 +21,7 @@ import org.springframework.retry.annotation.Backoff import org.springframework.retry.annotation.Retryable import org.springframework.security.access.prepost.PreAuthorize 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.RequestMapping @@ -68,6 +69,21 @@ class AdminChatCharacterController( ApiResponse.ok(response) } + /** + * 캐릭터 상세 정보 조회 API + * + * @param characterId 캐릭터 ID + * @return 캐릭터 상세 정보 + */ + @GetMapping("/{characterId}") + fun getCharacterDetail( + @PathVariable characterId: Long + ) = run { + val response = adminService.getChatCharacterDetail(characterId, imageHost) + + ApiResponse.ok(response) + } + @PostMapping("/register") @Retryable( value = [Exception::class], 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 new file mode 100644 index 0000000..e71b9fe --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDetailResponse.kt @@ -0,0 +1,83 @@ +package kr.co.vividnext.sodalive.admin.chat.character.dto + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter + +data class ChatCharacterDetailResponse( + val id: Long, + val characterUUID: String, + val name: String, + val imageUrl: String?, + val description: String, + val systemPrompt: String, + val age: Int?, + val gender: String?, + val mbti: String?, + val speechPattern: String?, + val speechStyle: String?, + val appearance: String?, + val isActive: Boolean, + val tags: List, + val hobbies: List, + val values: List, + val goals: List, + val relationships: List, + val personalities: List, + val backgrounds: List, + val memories: List +) { + companion object { + fun from(chatCharacter: ChatCharacter, imageHost: String = ""): ChatCharacterDetailResponse { + val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) { + "$imageHost/${chatCharacter.imagePath}" + } else { + chatCharacter.imagePath + } + + return ChatCharacterDetailResponse( + id = chatCharacter.id!!, + characterUUID = chatCharacter.characterUUID, + name = chatCharacter.name, + imageUrl = fullImagePath, + description = chatCharacter.description, + systemPrompt = chatCharacter.systemPrompt, + age = chatCharacter.age, + gender = chatCharacter.gender, + mbti = chatCharacter.mbti, + speechPattern = chatCharacter.speechPattern, + speechStyle = chatCharacter.speechStyle, + appearance = chatCharacter.appearance, + isActive = chatCharacter.isActive, + tags = chatCharacter.tagMappings.map { it.tag.tag }, + hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, + values = chatCharacter.valueMappings.map { it.value.value }, + goals = chatCharacter.goalMappings.map { it.goal.goal }, + relationships = chatCharacter.relationships.map { it.relationShip }, + personalities = chatCharacter.personalities.map { + PersonalityResponse(it.trait, it.description) + }, + backgrounds = chatCharacter.backgrounds.map { + BackgroundResponse(it.topic, it.description) + }, + memories = chatCharacter.memories.map { + MemoryResponse(it.title, it.content, it.emotion) + } + ) + } + } +} + +data class PersonalityResponse( + val trait: String, + val description: String +) + +data class BackgroundResponse( + val topic: String, + val description: String +) + +data class MemoryResponse( + val title: String, + val content: String, + val emotion: String +) 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 25433c2..b48c66e 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 @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.service +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -43,4 +45,20 @@ class AdminChatCharacterService( fun createDefaultPageRequest(page: Int = 0, size: Int = 20): PageRequest { return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")) } + + /** + * 캐릭터 상세 정보 조회 + * + * @param characterId 캐릭터 ID + * @param imageHost 이미지 호스트 URL + * @return 캐릭터 상세 정보 + * @throws SodaException 캐릭터를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + fun getChatCharacterDetail(characterId: Long, imageHost: String = ""): ChatCharacterDetailResponse { + val chatCharacter = chatCharacterRepository.findById(characterId) + .orElseThrow { SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") } + + return ChatCharacterDetailResponse.from(chatCharacter, imageHost) + } } From c729a402aa0a29c40d6f9703d6cfe2cd17c4a3be Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 14:36:48 +0900 Subject: [PATCH 010/119] =?UTF-8?q?feat(banner):=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95/=EC=82=AD=EC=A0=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/AdminChatBannerController.kt | 191 ++++++++++++++++++ .../dto/ChatCharacterSearchResponse.kt | 35 ++++ .../service/AdminChatCharacterService.kt | 20 ++ .../chat/dto/ChatCharacterBannerRequest.kt | 20 ++ .../chat/dto/ChatCharacterBannerResponse.kt | 37 ++++ .../chat/character/ChatCharacterBanner.kt | 25 +++ .../ChatCharacterBannerRepository.kt | 43 ++++ .../repository/ChatCharacterRepository.kt | 24 +++ .../service/ChatCharacterBannerService.kt | 114 +++++++++++ 9 files changed, 509 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt new file mode 100644 index 0000000..1a8f3ea --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -0,0 +1,191 @@ +package kr.co.vividnext.sodalive.admin.chat + +import com.amazonaws.services.s3.model.ObjectMetadata +import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService +import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse +import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest +import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse +import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService +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.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 + +@RestController +@RequestMapping("/api/admin/chat/banner") +@PreAuthorize("hasRole('ADMIN')") +class AdminChatBannerController( + private val bannerService: ChatCharacterBannerService, + private val adminCharacterService: AdminChatCharacterService, + private val s3Uploader: S3Uploader, + + @Value("\${cloud.aws.s3.bucket}") + private val s3Bucket: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + /** + * 활성화된 배너 목록 조회 API + * + * @param page 페이지 번호 (0부터 시작, 기본값 0) + * @param size 페이지 크기 (기본값 20) + * @return 페이징된 배너 목록 + */ + @GetMapping("/list") + fun getBannerList( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = run { + val pageable = adminCharacterService.createDefaultPageRequest(page, size) + val banners = bannerService.getActiveBanners(pageable) + val response = ChatCharacterBannerListPageResponse( + totalCount = banners.totalElements, + content = banners.content.map { ChatCharacterBannerResponse.from(it, imageHost) } + ) + + ApiResponse.ok(response) + } + + /** + * 배너 상세 조회 API + * + * @param bannerId 배너 ID + * @return 배너 상세 정보 + */ + @GetMapping("/{bannerId}") + fun getBannerDetail(@PathVariable bannerId: Long) = run { + val banner = bannerService.getBannerById(bannerId) + val response = ChatCharacterBannerResponse.from(banner, imageHost) + + ApiResponse.ok(response) + } + + /** + * 캐릭터 검색 API (배너 등록을 위한) + * + * @param searchTerm 검색어 (이름, 설명, MBTI, 태그) + * @param page 페이지 번호 (0부터 시작, 기본값 0) + * @param size 페이지 크기 (기본값 20) + * @return 검색된 캐릭터 목록 + */ + @GetMapping("/search-character") + fun searchCharacters( + @RequestParam searchTerm: String, + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "20") size: Int + ) = run { + val pageable = adminCharacterService.createDefaultPageRequest(page, size) + val response = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost) + + ApiResponse.ok(response) + } + + /** + * 배너 등록 API + * + * @param image 배너 이미지 + * @param request 배너 등록 요청 정보 + * @return 등록된 배너 정보 + */ + @PostMapping("/register") + fun registerBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: ChatCharacterBannerRegisterRequest + ) = run { + // 1. 먼저 빈 이미지 경로로 배너 등록 + val banner = bannerService.registerBanner(request.characterId, "") + + // 2. 배너 ID를 사용하여 이미지 업로드 + val imagePath = saveImage(banner.id!!, image) + + // 3. 이미지 경로로 배너 업데이트 + val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) + + val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost) + + ApiResponse.ok(response) + } + + /** + * 이미지를 S3에 업로드하고 경로를 반환 + * + * @param bannerId 배너 ID (이미지 경로에 사용) + * @param image 업로드할 이미지 파일 + * @return 업로드된 이미지 경로 + */ + private fun saveImage(bannerId: Long, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + + val fileName = generateFileName("character-banner") + + // S3에 이미지 업로드 (배너 ID를 경로에 사용) + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = "/characters/banners/$bannerId/$fileName", + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } + + /** + * 배너 수정 API + * + * @param image 배너 이미지 + * @param request 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함) + * @return 수정된 배너 정보 + */ + @PutMapping("/update") + fun updateBanner( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") request: ChatCharacterBannerUpdateRequest + ) = run { + // 배너 정보 조회 + bannerService.getBannerById(request.bannerId) + + // 배너 ID를 사용하여 이미지 업로드 + val imagePath = saveImage(request.bannerId, image) + + // 배너 수정 (이미지와 캐릭터 모두 수정 가능) + val updatedBanner = bannerService.updateBanner( + bannerId = request.bannerId, + imagePath = imagePath, + characterId = request.characterId + ) + + val response = ChatCharacterBannerResponse.from(updatedBanner, imageHost) + + ApiResponse.ok(response) + } + + /** + * 배너 삭제 API (소프트 삭제) + * + * @param bannerId 배너 ID + * @return 성공 여부 + */ + @DeleteMapping("/{bannerId}") + fun deleteBanner(@PathVariable bannerId: Long) = run { + bannerService.deleteBanner(bannerId) + + ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt new file mode 100644 index 0000000..e4e06ad --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.admin.chat.character.dto + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import org.springframework.data.domain.Page + +/** + * 캐릭터 검색 결과 응답 DTO + */ +data class ChatCharacterSearchResponse( + val id: Long, + val name: String, + val description: String, + val mbti: String?, + val imagePath: String?, + val tags: List +) { + companion object { + fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse { + val tags = character.tagMappings.map { it.tag.tag } + + return ChatCharacterSearchResponse( + id = character.id!!, + name = character.name, + description = character.description, + mbti = character.mbti, + imagePath = character.imagePath?.let { "$imageHost$it" }, + tags = tags + ) + } + + fun fromPage(characters: Page, imageHost: String): Page { + return characters.map { from(it, imageHost) } + } + } +} 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 b48c66e..faa96e3 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 @@ -3,8 +3,10 @@ package kr.co.vividnext.sodalive.admin.chat.character.service import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterDetailResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterListResponse +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchResponse import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import org.springframework.data.domain.Sort @@ -61,4 +63,22 @@ class AdminChatCharacterService( return ChatCharacterDetailResponse.from(chatCharacter, imageHost) } + + /** + * 캐릭터 검색 (이름, 설명, MBTI, 태그 기반) + * + * @param searchTerm 검색어 + * @param pageable 페이징 정보 + * @param imageHost 이미지 호스트 URL + * @return 검색된 캐릭터 목록 (페이징) + */ + @Transactional(readOnly = true) + fun searchCharacters( + searchTerm: String, + pageable: Pageable, + imageHost: String = "" + ): Page { + val characters = chatCharacterRepository.searchCharacters(searchTerm, pageable) + return characters.map { ChatCharacterSearchResponse.from(it, imageHost) } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt new file mode 100644 index 0000000..551150c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.admin.chat.dto + +/** + * 캐릭터 배너 등록 요청 DTO + */ +data class ChatCharacterBannerRegisterRequest( + // 캐릭터 ID + val characterId: Long +) + +/** + * 캐릭터 배너 수정 요청 DTO + */ +data class ChatCharacterBannerUpdateRequest( + // 배너 ID + val bannerId: Long, + + // 캐릭터 ID (변경할 캐릭터) + val characterId: Long? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt new file mode 100644 index 0000000..05012b7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.admin.chat.dto + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner +import org.springframework.data.domain.Page + +/** + * 캐릭터 배너 응답 DTO + */ +data class ChatCharacterBannerResponse( + val id: Long, + val imagePath: String, + val characterId: Long, + val characterName: String +) { + companion object { + fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse { + return ChatCharacterBannerResponse( + id = banner.id!!, + imagePath = "$imageHost${banner.imagePath}", + characterId = banner.chatCharacter.id!!, + characterName = banner.chatCharacter.name + ) + } + + fun fromPage(banners: Page, imageHost: String): Page { + return banners.map { from(it, imageHost) } + } + } +} + +/** + * 캐릭터 배너 목록 페이지 응답 DTO + */ +data class ChatCharacterBannerListPageResponse( + val totalCount: Long, + val content: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt new file mode 100644 index 0000000..643597b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.chat.character + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 캐릭터 배너 엔티티 + * 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다. + */ +@Entity +class ChatCharacterBanner( + // 배너 이미지 경로 + var imagePath: String? = null, + + // 연관된 캐릭터 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id") + var chatCharacter: ChatCharacter, + + // 활성화 여부 (소프트 삭제용) + var isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt new file mode 100644 index 0000000..b849c7e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.chat.character.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner +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 + +@Repository +interface ChatCharacterBannerRepository : JpaRepository { + // 활성화된 배너 목록 조회 + fun findByIsActiveTrue(pageable: Pageable): Page + + // 특정 캐릭터의 활성화된 배너 목록 조회 + fun findByChatCharacterAndIsActiveTrue(chatCharacter: ChatCharacter): List + + // 특정 캐릭터 ID의 활성화된 배너 목록 조회 + fun findByChatCharacter_IdAndIsActiveTrue(characterId: Long): List + + // 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) + @Query( + """ + SELECT DISTINCT b FROM ChatCharacterBanner b + JOIN FETCH b.chatCharacter c + LEFT JOIN c.tagMappings tm + LEFT JOIN tm.tag t + WHERE b.isActive = true AND 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, '%'))) + ) + """ + ) + fun searchBannersByCharacterAttributes( + @Param("searchTerm") searchTerm: String, + pageable: Pageable + ): Page +} 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 f5ab3dc..30ceb7c 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 @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter 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 @Repository @@ -11,4 +13,26 @@ interface ChatCharacterRepository : JpaRepository { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? fun findByActiveTrue(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, '%'))) + ) + """ + ) + fun searchCharacters( + @Param("searchTerm") searchTerm: String, + pageable: Pageable + ): Page } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt new file mode 100644 index 0000000..b9175bc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -0,0 +1,114 @@ +package kr.co.vividnext.sodalive.chat.character.service + +import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatCharacterBannerService( + private val bannerRepository: ChatCharacterBannerRepository, + private val characterRepository: ChatCharacterRepository +) { + /** + * 활성화된 모든 배너 조회 + */ + fun getActiveBanners(pageable: Pageable): Page { + return bannerRepository.findByIsActiveTrue(pageable) + } + + /** + * 배너 상세 조회 + */ + fun getBannerById(bannerId: Long): ChatCharacterBanner { + return bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + } + + /** + * 특정 캐릭터의 활성화된 배너 목록 조회 + */ + fun getActiveBannersByCharacterId(characterId: Long): List { + return bannerRepository.findByChatCharacter_IdAndIsActiveTrue(characterId) + } + + /** + * 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) + */ + fun searchBannersByCharacterAttributes(searchTerm: String, pageable: Pageable): Page { + return bannerRepository.searchBannersByCharacterAttributes(searchTerm, pageable) + } + + /** + * 배너 등록 + */ + @Transactional + fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + + if (!character.isActive) { + throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId") + } + + val banner = ChatCharacterBanner( + imagePath = imagePath, + chatCharacter = character + ) + + return bannerRepository.save(banner) + } + + /** + * 배너 수정 + * + * @param bannerId 배너 ID + * @param imagePath 이미지 경로 (변경할 경우) + * @param characterId 캐릭터 ID (변경할 경우) + * @return 수정된 배너 + */ + @Transactional + fun updateBanner(bannerId: Long, imagePath: String? = null, characterId: Long? = null): ChatCharacterBanner { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + + if (!banner.isActive) { + throw SodaException("비활성화된 배너는 수정할 수 없습니다: $bannerId") + } + + // 이미지 경로 변경 + if (imagePath != null) { + banner.imagePath = imagePath + } + + // 캐릭터 변경 + if (characterId != null) { + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + + if (!character.isActive) { + throw SodaException("비활성화된 캐릭터로는 변경할 수 없습니다: $characterId") + } + + banner.chatCharacter = character + } + + return bannerRepository.save(banner) + } + + /** + * 배너 삭제 (소프트 삭제) + */ + @Transactional + fun deleteBanner(bannerId: Long) { + val banner = bannerRepository.findById(bannerId) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } + + banner.isActive = false + bannerRepository.save(banner) + } +} From 81f972edc16339e6c65dbab6dcf4d50b07149839 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 14:45:28 +0900 Subject: [PATCH 011/119] =?UTF-8?q?fix(banner):=20ChatCharacterBanner=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=9D=98=20isActive=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=B0=B8=EC=A1=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용하지 않는 메서드 제거 --- .../dto/ChatCharacterSearchResponse.kt | 5 --- .../chat/dto/ChatCharacterBannerResponse.kt | 5 --- .../ChatCharacterBannerRepository.kt | 32 +------------------ .../service/ChatCharacterBannerService.kt | 16 +--------- 4 files changed, 2 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt index e4e06ad..4678747 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import org.springframework.data.domain.Page /** * 캐릭터 검색 결과 응답 DTO @@ -27,9 +26,5 @@ data class ChatCharacterSearchResponse( tags = tags ) } - - fun fromPage(characters: Page, imageHost: String): Page { - return characters.map { from(it, imageHost) } - } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt index 05012b7..4afd07b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.admin.chat.dto import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner -import org.springframework.data.domain.Page /** * 캐릭터 배너 응답 DTO @@ -21,10 +20,6 @@ data class ChatCharacterBannerResponse( characterName = banner.chatCharacter.name ) } - - fun fromPage(banners: Page, imageHost: String): Page { - return banners.map { from(it, imageHost) } - } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt index b849c7e..0f3d403 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -1,43 +1,13 @@ package kr.co.vividnext.sodalive.chat.character.repository -import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner 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 @Repository interface ChatCharacterBannerRepository : JpaRepository { // 활성화된 배너 목록 조회 - fun findByIsActiveTrue(pageable: Pageable): Page - - // 특정 캐릭터의 활성화된 배너 목록 조회 - fun findByChatCharacterAndIsActiveTrue(chatCharacter: ChatCharacter): List - - // 특정 캐릭터 ID의 활성화된 배너 목록 조회 - fun findByChatCharacter_IdAndIsActiveTrue(characterId: Long): List - - // 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) - @Query( - """ - SELECT DISTINCT b FROM ChatCharacterBanner b - JOIN FETCH b.chatCharacter c - LEFT JOIN c.tagMappings tm - LEFT JOIN tm.tag t - WHERE b.isActive = true AND 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, '%'))) - ) - """ - ) - fun searchBannersByCharacterAttributes( - @Param("searchTerm") searchTerm: String, - pageable: Pageable - ): Page + fun findByActiveTrue(pageable: Pageable): Page } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index b9175bc..a96e7e2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -18,7 +18,7 @@ class ChatCharacterBannerService( * 활성화된 모든 배너 조회 */ fun getActiveBanners(pageable: Pageable): Page { - return bannerRepository.findByIsActiveTrue(pageable) + return bannerRepository.findByActiveTrue(pageable) } /** @@ -29,20 +29,6 @@ class ChatCharacterBannerService( .orElseThrow { SodaException("배너를 찾을 수 없습니다: $bannerId") } } - /** - * 특정 캐릭터의 활성화된 배너 목록 조회 - */ - fun getActiveBannersByCharacterId(characterId: Long): List { - return bannerRepository.findByChatCharacter_IdAndIsActiveTrue(characterId) - } - - /** - * 이름, 설명, MBTI, 태그로 캐릭터 검색 (배너 포함) - */ - fun searchBannersByCharacterAttributes(searchTerm: String, pageable: Pageable): Page { - return bannerRepository.searchBannersByCharacterAttributes(searchTerm, pageable) - } - /** * 배너 등록 */ From ef8458c7a32abce7d802607b3d0ad001241b5419 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 15:31:03 +0900 Subject: [PATCH 012/119] =?UTF-8?q?feat(banner):=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/AdminChatBannerController.kt | 27 +++++++++++-- .../chat/dto/ChatCharacterBannerRequest.kt | 8 ++++ .../chat/character/ChatCharacterBanner.kt | 4 ++ .../ChatCharacterBannerRepository.kt | 9 ++++- .../service/ChatCharacterBannerService.kt | 40 +++++++++++++++++-- 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 1a8f3ea..2c4b4b0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageRespon import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerResponse import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerUpdateRequest +import kr.co.vividnext.sodalive.admin.chat.dto.UpdateBannerOrdersRequest import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.common.ApiResponse @@ -18,6 +19,7 @@ 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 @@ -98,7 +100,7 @@ class AdminChatBannerController( * 배너 등록 API * * @param image 배너 이미지 - * @param request 배너 등록 요청 정보 + * @param request 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함) * @return 등록된 배너 정보 */ @PostMapping("/register") @@ -106,8 +108,11 @@ class AdminChatBannerController( @RequestPart("image") image: MultipartFile, @RequestPart("request") request: ChatCharacterBannerRegisterRequest ) = run { - // 1. 먼저 빈 이미지 경로로 배너 등록 - val banner = bannerService.registerBanner(request.characterId, "") + // 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함) + val banner = bannerService.registerBanner( + characterId = request.characterId, + imagePath = "" + ) // 2. 배너 ID를 사용하여 이미지 업로드 val imagePath = saveImage(banner.id!!, image) @@ -188,4 +193,20 @@ class AdminChatBannerController( ApiResponse.ok("배너가 성공적으로 삭제되었습니다.") } + + /** + * 배너 정렬 순서 일괄 변경 API + * ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다. + * + * @param request 정렬 순서 일괄 변경 요청 정보 (배너 ID 목록) + * @return 성공 메시지 + */ + @PutMapping("/orders") + fun updateBannerOrders( + @RequestBody request: UpdateBannerOrdersRequest + ) = run { + bannerService.updateBannerOrders(request.ids) + + ApiResponse.ok(null, "배너 정렬 순서가 성공적으로 변경되었습니다.") + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt index 551150c..c4f6b33 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -18,3 +18,11 @@ data class ChatCharacterBannerUpdateRequest( // 캐릭터 ID (변경할 캐릭터) val characterId: Long? = null ) + +/** + * 캐릭터 배너 정렬 순서 일괄 변경 요청 DTO + */ +data class UpdateBannerOrdersRequest( + // 배너 ID 목록 (순서대로 정렬됨) + val ids: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt index 643597b..055f3a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBanner.kt @@ -9,6 +9,7 @@ import javax.persistence.ManyToOne /** * 캐릭터 배너 엔티티 * 이미지와 캐릭터 ID를 가지며, 소프트 삭제(isActive = false)를 지원합니다. + * 정렬 순서(sortOrder)를 통해 배너의 표시 순서를 결정합니다. */ @Entity class ChatCharacterBanner( @@ -20,6 +21,9 @@ class ChatCharacterBanner( @JoinColumn(name = "character_id") var chatCharacter: ChatCharacter, + // 정렬 순서 (낮을수록 먼저 표시) + var sortOrder: Int = 0, + // 활성화 여부 (소프트 삭제용) var isActive: Boolean = true ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt index 0f3d403..8b7ef53 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -4,10 +4,15 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner 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.stereotype.Repository @Repository interface ChatCharacterBannerRepository : JpaRepository { - // 활성화된 배너 목록 조회 - fun findByActiveTrue(pageable: Pageable): Page + // 활성화된 배너 목록 조회 (정렬 순서대로) + fun findByActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + + // 활성화된 배너 중 최대 정렬 순서 값 조회 + @Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true") + fun findMaxSortOrder(): Int? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index a96e7e2..6abc5c6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -15,10 +15,10 @@ class ChatCharacterBannerService( private val characterRepository: ChatCharacterRepository ) { /** - * 활성화된 모든 배너 조회 + * 활성화된 모든 배너 조회 (정렬 순서대로) */ fun getActiveBanners(pageable: Pageable): Page { - return bannerRepository.findByActiveTrue(pageable) + return bannerRepository.findByActiveTrueOrderBySortOrderAsc(pageable) } /** @@ -31,6 +31,10 @@ class ChatCharacterBannerService( /** * 배너 등록 + * + * @param characterId 캐릭터 ID + * @param imagePath 이미지 경로 + * @return 등록된 배너 */ @Transactional fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner { @@ -41,9 +45,13 @@ class ChatCharacterBannerService( throw SodaException("비활성화된 캐릭터에는 배너를 등록할 수 없습니다: $characterId") } + // 정렬 순서가 지정되지 않은 경우 가장 마지막 순서로 설정 + val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 + val banner = ChatCharacterBanner( imagePath = imagePath, - chatCharacter = character + chatCharacter = character, + sortOrder = finalSortOrder ) return bannerRepository.save(banner) @@ -97,4 +105,30 @@ class ChatCharacterBannerService( banner.isActive = false bannerRepository.save(banner) } + + /** + * 배너 정렬 순서 일괄 변경 + * ID 목록의 순서대로 정렬 순서를 1부터 순차적으로 설정합니다. + * + * @param ids 배너 ID 목록 (순서대로 정렬됨) + * @return 수정된 배너 목록 + */ + @Transactional + fun updateBannerOrders(ids: List): List { + val updatedBanners = mutableListOf() + + for (index in ids.indices) { + val banner = bannerRepository.findById(ids[index]) + .orElseThrow { SodaException("배너를 찾을 수 없습니다: ${ids[index]}") } + + if (!banner.isActive) { + throw SodaException("비활성화된 배너는 수정할 수 없습니다: ${ids[index]}") + } + + banner.sortOrder = index + 1 + updatedBanners.add(bannerRepository.save(banner)) + } + + return updatedBanners + } } From add21c45c573ddf67e775cb5f8e686e68e91b450 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 16:01:53 +0900 Subject: [PATCH 013/119] =?UTF-8?q?fix(=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=84=B1=EA=B2=A9=ED=8A=B9=EC=84=B1):=20description=20SQL=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=ED=83=80=EC=9E=85=20TEXT=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/ChatCharacterPersonality.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt index d6bd7b3..647c234 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -16,6 +17,7 @@ class ChatCharacterPersonality( val trait: String, // 성격 특성 설명 + @Column(columnDefinition = "TEXT", nullable = false) val description: String, @ManyToOne(fetch = FetchType.LAZY) From 00016972749e927a14b3bc542caaca7f87e9aed5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 16:15:56 +0900 Subject: [PATCH 014/119] =?UTF-8?q?fix:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EA=B0=92=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0c81b0a..5620001 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,8 +9,8 @@ logging: EC2MetadataUtils: error weraser: - apiUrl: {$WERASER_API_URL} - apiKey: {$WERASER_API_KEY} + apiUrl: ${WERASER_API_URL} + apiKey: ${WERASER_API_KEY} bootpay: applicationId: ${BOOTPAY_APPLICATION_ID} From 206c25985a17664e4f4f73323fbeb284dca16f5c Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 16:52:41 +0900 Subject: [PATCH 015/119] =?UTF-8?q?fix:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20-=20active=20->?= =?UTF-8?q?=20isActive=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/service/AdminChatCharacterService.kt | 2 +- .../chat/character/repository/ChatCharacterRepository.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 faa96e3..07b7c7e 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 @@ -26,7 +26,7 @@ class AdminChatCharacterService( @Transactional(readOnly = true) fun getActiveChatCharacters(pageable: Pageable, imageHost: String = ""): ChatCharacterListPageResponse { // isActive가 true인 캐릭터만 조회 - val page = chatCharacterRepository.findByActiveTrue(pageable) + val page = chatCharacterRepository.findByIsActiveTrue(pageable) // 페이지 정보 생성 val content = page.content.map { ChatCharacterListResponse.from(it, imageHost) } 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 30ceb7c..464e1b1 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,7 +12,7 @@ import org.springframework.stereotype.Repository interface ChatCharacterRepository : JpaRepository { fun findByCharacterUUID(characterUUID: String): ChatCharacter? fun findByName(name: String): ChatCharacter? - fun findByActiveTrue(pageable: Pageable): Page + fun findByIsActiveTrue(pageable: Pageable): Page /** * 이름, 설명, MBTI, 태그로 캐릭터 검색 From 74ed7b20ba831d5ad0033ad602d92124391941db Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 20:48:27 +0900 Subject: [PATCH 016/119] =?UTF-8?q?feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=88=98=EC=A0=95=20Request=20-=20JsonPrope?= =?UTF-8?q?rty=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/dto/ChatCharacterDto.kt | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) 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 94cf606..717bcd6 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 @@ -1,40 +1,41 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto +import com.fasterxml.jackson.annotation.JsonProperty + data class ChatCharacterPersonalityRequest( - val trait: String, - val description: String + @JsonProperty("trait") val trait: String, + @JsonProperty("description") val description: String ) data class ChatCharacterBackgroundRequest( - val topic: String, - val description: String + @JsonProperty("topic") val topic: String, + @JsonProperty("description") val description: String ) data class ChatCharacterMemoryRequest( - val title: String, - val content: String, - val emotion: String + @JsonProperty("title") val title: String, + @JsonProperty("content") val content: String, + @JsonProperty("emotion") val emotion: String ) data class ChatCharacterRegisterRequest( - val name: String, - val systemPrompt: String, - val description: String, - val age: String?, - val gender: String?, - val mbti: String?, - val speechPattern: String?, - val speechStyle: String?, - val appearance: String?, - val isActive: Boolean = true, - val tags: List = emptyList(), - val hobbies: List = emptyList(), - val values: List = emptyList(), - val goals: List = emptyList(), - val relationships: List = emptyList(), - val personalities: List = emptyList(), - val backgrounds: List = emptyList(), - val memories: List = emptyList() + @JsonProperty("name") val name: String, + @JsonProperty("systemPrompt") val systemPrompt: String, + @JsonProperty("description") val description: String, + @JsonProperty("age") val age: String?, + @JsonProperty("gender") val gender: String?, + @JsonProperty("mbti") val mbti: String?, + @JsonProperty("speechPattern") val speechPattern: String?, + @JsonProperty("speechStyle") val speechStyle: String?, + @JsonProperty("appearance") val appearance: String?, + @JsonProperty("tags") val tags: List = emptyList(), + @JsonProperty("hobbies") val hobbies: List = emptyList(), + @JsonProperty("values") val values: List = emptyList(), + @JsonProperty("goals") val goals: List = emptyList(), + @JsonProperty("relationships") val relationships: List = emptyList(), + @JsonProperty("personalities") val personalities: List = emptyList(), + @JsonProperty("backgrounds") val backgrounds: List = emptyList(), + @JsonProperty("memories") val memories: List = emptyList() ) data class ExternalApiResponse( @@ -48,23 +49,23 @@ data class ExternalApiData( ) data class ChatCharacterUpdateRequest( - val id: Long, - val name: String? = null, - val systemPrompt: String? = null, - val description: String? = null, - val age: String? = null, - val gender: String? = null, - val mbti: String? = null, - val speechPattern: String? = null, - val speechStyle: String? = null, - val appearance: String? = null, - val isActive: Boolean? = null, - val tags: List? = null, - val hobbies: List? = null, - val values: List? = null, - val goals: List? = null, - val relationships: List? = null, - val personalities: List? = null, - val backgrounds: List? = null, - val memories: List? = null + @JsonProperty("id") val id: Long, + @JsonProperty("name") val name: String? = null, + @JsonProperty("systemPrompt") val systemPrompt: String? = null, + @JsonProperty("description") val description: String? = null, + @JsonProperty("age") val age: String? = null, + @JsonProperty("gender") val gender: String? = null, + @JsonProperty("mbti") val mbti: String? = null, + @JsonProperty("speechPattern") val speechPattern: String? = null, + @JsonProperty("speechStyle") val speechStyle: String? = null, + @JsonProperty("appearance") val appearance: String? = null, + @JsonProperty("isActive") val isActive: Boolean? = null, + @JsonProperty("tags") val tags: List? = null, + @JsonProperty("hobbies") val hobbies: List? = null, + @JsonProperty("values") val values: List? = null, + @JsonProperty("goals") val goals: List? = null, + @JsonProperty("relationships") val relationships: List? = null, + @JsonProperty("personalities") val personalities: List? = null, + @JsonProperty("backgrounds") val backgrounds: List? = null, + @JsonProperty("memories") val memories: List? = null ) From b0a6fc649889b1bce5854bbee1dec66fae415796 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 21:18:29 +0900 Subject: [PATCH 017/119] =?UTF-8?q?feat:=20weraser=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B6=80=EB=B6=84=20-=20exception=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=EC=8B=9C=20exception=20message=EB=8F=84=20=EA=B0=99?= =?UTF-8?q?=EC=9D=B4=20=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/AdminChatCharacterController.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 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 9c05dca..95f848c 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 @@ -172,8 +172,9 @@ class AdminChatCharacterController( // success가 true이면 data.id 반환 return apiResponse.data?.id ?: throw SodaException("등록에 실패했습니다. 응답에 ID가 없습니다.") - } catch (_: Exception) { - throw SodaException("등록에 실패했습니다. 다시 시도해 주세요.") + } catch (e: Exception) { + e.printStackTrace() + throw SodaException("${e.message}, 등록에 실패했습니다. 다시 시도해 주세요.") } } From a1533c8e980071ac74b326d43b8eee3ca27d68bc Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 22:33:29 +0900 Subject: [PATCH 018/119] =?UTF-8?q?feat(character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=A9=94=EC=9D=B8=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatCharacterController.kt | 88 +++++++++++++++++++ .../character/dto/CharacterHomeResponse.kt | 33 +++++++ .../ChatCharacterBannerRepository.kt | 2 +- .../service/ChatCharacterBannerService.kt | 2 +- .../character/service/ChatCharacterService.kt | 31 +++++++ .../sodalive/configs/SecurityConfig.kt | 1 + 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt new file mode 100644 index 0000000..5b024e4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -0,0 +1,88 @@ +package kr.co.vividnext.sodalive.chat.character.controller + +import kr.co.vividnext.sodalive.chat.character.dto.Character +import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse +import kr.co.vividnext.sodalive.chat.character.dto.CharacterMainResponse +import kr.co.vividnext.sodalive.chat.character.dto.CurationSection +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.ChatCharacterService +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/character") +class ChatCharacterController( + private val service: ChatCharacterService, + private val bannerService: ChatCharacterBannerService, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + @GetMapping("/main") + fun getCharacterMain( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ): ApiResponse = run { + // 배너 조회 (최대 10개) + val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) + .content + .map { + CharacterBannerResponse( + characterId = it.chatCharacter.id!!, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + + // 최근 대화한 캐릭터 조회 (현재는 빈 리스트) + val recentCharacters = service.getRecentCharacters() + .map { + RecentCharacter( + characterId = it.id!!, + name = it.name, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + + // 인기 캐릭터 조회 (현재는 빈 리스트) + val popularCharacters = service.getPopularCharacters() + .map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + + // 최신 캐릭터 조회 (최대 10개) + val newCharacters = service.getNewCharacters(10) + .map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + + // 큐레이션 섹션 (현재는 빈 리스트) + val curationSections = emptyList() + + // 응답 생성 + ApiResponse.ok( + CharacterMainResponse( + banners = banners, + recentCharacters = recentCharacters, + popularCharacters = popularCharacters, + newCharacters = newCharacters, + curationSections = curationSections + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt new file mode 100644 index 0000000..b471315 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.chat.character.dto + +data class CharacterMainResponse( + val banners: List, + val recentCharacters: List, + val popularCharacters: List, + val newCharacters: List, + val curationSections: List +) + +data class CurationSection( + val characterCurationId: Long, + val title: String, + val characters: List +) + +data class Character( + val characterId: Long, + val name: String, + val description: String, + val imageUrl: String +) + +data class RecentCharacter( + val characterId: Long, + val name: String, + val imageUrl: String +) + +data class CharacterBannerResponse( + val characterId: Long, + val imageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt index 8b7ef53..2de9020 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterBannerRepository.kt @@ -10,7 +10,7 @@ import org.springframework.stereotype.Repository @Repository interface ChatCharacterBannerRepository : JpaRepository { // 활성화된 배너 목록 조회 (정렬 순서대로) - fun findByActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page + fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page // 활성화된 배너 중 최대 정렬 순서 값 조회 @Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt index 6abc5c6..1eeaadb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterBannerService.kt @@ -18,7 +18,7 @@ class ChatCharacterBannerService( * 활성화된 모든 배너 조회 (정렬 순서대로) */ fun getActiveBanners(pageable: Pageable): Page { - return bannerRepository.findByActiveTrueOrderBySortOrderAsc(pageable) + return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) } /** 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 ab4254a..ba958cc 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 @@ -23,6 +23,37 @@ class ChatCharacterService( private val goalRepository: ChatCharacterGoalRepository ) { + /** + * 최근에 대화한 캐릭터 목록 조회 + * 현재는 채팅방 구현 전이므로 빈 리스트 반환 + */ + @Transactional(readOnly = true) + fun getRecentCharacters(): List { + // 채팅방 구현 전이므로 빈 리스트 반환 + return emptyList() + } + + /** + * 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회 + * 현재는 채팅방 구현 전이므로 빈 리스트 반환 + */ + @Transactional(readOnly = true) + fun getPopularCharacters(): List { + // 채팅방 구현 전이므로 빈 리스트 반환 + return emptyList() + } + + /** + * 최근 등록된 캐릭터 목록 조회 (최대 10개) + */ + @Transactional(readOnly = true) + fun getNewCharacters(limit: Int = 10): List { + return chatCharacterRepository.findAll() + .filter { it.isActive } + .sortedByDescending { it.createdAt } + .take(limit) + } + /** * 태그를 찾거나 생성하여 캐릭터에 연결 */ 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 0f01436..78fb478 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -93,6 +93,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() + .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .anyRequest().authenticated() .and() .build() From 7e7a1122fa7f62977e4d0b4a3f1dcbe495885f7e Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 22:40:06 +0900 Subject: [PATCH 019/119] =?UTF-8?q?refactor(character):=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EB=93=B1=EB=A1=9D=EB=90=9C=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조회할 때부터 isActive = true, limit 10개를 불러오도록 리팩토링 - ChatCharacterRepository에 findByIsActiveTrueOrderByCreatedAtDesc 메소드 추가 - ChatCharacterService의 getNewCharacters 메소드 수정 --- .../chat/character/repository/ChatCharacterRepository.kt | 5 +++++ .../sodalive/chat/character/service/ChatCharacterService.kt | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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 464e1b1..59a617c 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 @@ -14,6 +14,11 @@ interface ChatCharacterRepository : JpaRepository { fun findByName(name: String): ChatCharacter? fun findByIsActiveTrue(pageable: Pageable): Page + /** + * 활성화된 캐릭터를 생성일 기준 내림차순으로 조회 + */ + fun findByIsActiveTrueOrderByCreatedAtDesc(pageable: Pageable): List + /** * 이름, 설명, MBTI, 태그로 캐릭터 검색 */ 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 ba958cc..574ee72 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 @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -48,10 +49,7 @@ class ChatCharacterService( */ @Transactional(readOnly = true) fun getNewCharacters(limit: Int = 10): List { - return chatCharacterRepository.findAll() - .filter { it.isActive } - .sortedByDescending { it.createdAt } - .take(limit) + return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) } /** From 60172ae84d121ea699f338317dcba4b14444a70e Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 23:10:36 +0900 Subject: [PATCH 020/119] =?UTF-8?q?feat(character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=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 - 캐릭터 ID로 상세 정보를 조회하는 API 엔드포인트 추가 - 캐릭터 상세 정보 조회 서비스 메서드 구현 - 캐릭터 상세 정보 응답 DTO 클래스 추가 --- .../controller/ChatCharacterController.kt | 78 +++++++++++++++++++ .../character/dto/CharacterDetailResponse.kt | 38 +++++++++ .../character/service/ChatCharacterService.kt | 21 +++++ 3 files changed, 137 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 5b024e4..5bee186 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -1,18 +1,24 @@ package kr.co.vividnext.sodalive.chat.character.controller import kr.co.vividnext.sodalive.chat.character.dto.Character +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.CharacterDetailResponse 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.CurationSection 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.ChatCharacterService 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.data.domain.PageRequest 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 @@ -85,4 +91,76 @@ class ChatCharacterController( ) ) } + + /** + * 캐릭터 상세 정보 조회 API + * 캐릭터 ID를 받아 해당 캐릭터의 상세 정보를 반환합니다. + */ + @GetMapping("/{characterId}") + fun getCharacterDetail( + @PathVariable characterId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + // 캐릭터 상세 정보 조회 + val character = service.getCharacterDetail(characterId) + ?: throw SodaException("캐릭터를 찾을 수 없습니다.") + + // 태그, 가치관, 취미, 목표 추출 + val tags = character.tagMappings.map { it.tag.tag } + val values = character.valueMappings.map { it.value.value } + val hobbies = character.hobbyMappings.map { it.hobby.hobby } + val goals = character.goalMappings.map { it.goal.goal } + + // 메모리, 성격, 배경, 관계 변환 + val memories = character.memories.map { + CharacterMemoryResponse( + title = it.title, + content = it.content, + emotion = it.emotion + ) + } + + val personalities = character.personalities.map { + CharacterPersonalityResponse( + trait = it.trait, + description = it.description + ) + } + + val backgrounds = character.backgrounds.map { + CharacterBackgroundResponse( + topic = it.topic, + description = it.description + ) + } + + val relationships = character.relationships.map { it.relationShip } + + // 응답 생성 + ApiResponse.ok( + CharacterDetailResponse( + characterId = character.id!!, + name = character.name, + description = character.description, + age = character.age, + gender = character.gender, + mbti = character.mbti, + speechPattern = character.speechPattern, + speechStyle = character.speechStyle, + appearance = character.appearance, + imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", + memories = memories, + personalities = personalities, + backgrounds = backgrounds, + relationships = relationships, + tags = tags, + values = values, + hobbies = hobbies, + goals = goals + ) + ) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt new file mode 100644 index 0000000..d9f2492 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.chat.character.dto + +data class CharacterDetailResponse( + val characterId: Long, + val name: String, + val description: String, + val age: Int?, + val gender: String?, + val mbti: String?, + val speechPattern: String?, + val speechStyle: String?, + val appearance: String?, + val imageUrl: String, + val memories: List = emptyList(), + val personalities: List = emptyList(), + val backgrounds: List = emptyList(), + val relationships: List = emptyList(), + val tags: List = emptyList(), + val values: List = emptyList(), + val hobbies: List = emptyList(), + val goals: List = emptyList() +) + +data class CharacterMemoryResponse( + val title: String, + val content: String, + val emotion: String +) + +data class CharacterPersonalityResponse( + val trait: String, + val description: String +) + +data class CharacterBackgroundResponse( + val topic: String, + val description: String +) 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 574ee72..0f9f626 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 @@ -172,6 +172,27 @@ class ChatCharacterService( return chatCharacterRepository.findById(id).orElse(null) } + /** + * 캐릭터 ID로 상세 정보를 조회합니다. + * 태그, 가치관, 취미, 목표 등의 관계 정보도 함께 조회합니다. + */ + @Transactional(readOnly = true) + fun getCharacterDetail(id: Long): ChatCharacter? { + val character = findById(id) ?: return null + + // 지연 로딩된 관계 데이터 초기화 + character.tagMappings.size + character.valueMappings.size + character.hobbyMappings.size + character.goalMappings.size + character.memories.size + character.personalities.size + character.backgrounds.size + character.relationships.size + + return character + } + /** * 캐릭터 생성 및 관련 엔티티 연결 */ From 694d9cd05a5fe56ced9fa1cfafdc8a85c39136ea Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 7 Aug 2025 23:35:57 +0900 Subject: [PATCH 021/119] =?UTF-8?q?feat(character=20chat=20room):=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9,=20=EC=B1=84=ED=8C=85=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80,=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/room/CharacterChatMessage.kt | 22 ++++++++++ .../chat/room/CharacterChatParticipant.kt | 40 +++++++++++++++++++ .../sodalive/chat/room/CharacterChatRoom.kt | 20 ++++++++++ 3 files changed, 82 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt new file mode 100644 index 0000000..88439df --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.room + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +@Entity +class CharacterChatMessage( + val message: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + val chatRoom: CharacterChatRoom, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "participant_id", nullable = false) + val participant: CharacterChatParticipant, + + val isActive: Boolean = true +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt new file mode 100644 index 0000000..60e1d2c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.chat.room + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.CascadeType +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 +class CharacterChatParticipant( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id", nullable = false) + val chatRoom: CharacterChatRoom, + + @Enumerated(EnumType.STRING) + val participantType: ParticipantType, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member: Member? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id") + val character: ChatCharacter? = null, + + val isActive: Boolean = true +) : BaseEntity() { + @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + val messages: MutableList = mutableListOf() +} + +enum class ParticipantType { + USER, CHARACTER +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt new file mode 100644 index 0000000..adea2b5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt @@ -0,0 +1,20 @@ +package kr.co.vividnext.sodalive.chat.room + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.OneToMany + +@Entity +class CharacterChatRoom( + val sessionId: String, + val title: String, + val isActive: Boolean = true +) : BaseEntity() { + @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + val messages: MutableList = mutableListOf() + + @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + val participants: MutableList = mutableListOf() +} From 1bafbed17cf2ecd4f25af45c5b39436161fd1240 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 00:27:25 +0900 Subject: [PATCH 022/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=83=9D=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅방 생성 및 조회 기능 구현 - 외부 API 연동을 통한 세션 생성 로직 추가 - 채팅방 참여자(유저, 캐릭터) 추가 기능 구현 - UUID 기반 유저 ID 생성 로직 추가 --- .../room/controller/ChatRoomController.kt | 45 +++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 46 +++++ .../CharacterChatParticipantRepository.kt | 36 ++++ .../repository/CharacterChatRoomRepository.kt | 35 ++++ .../chat/room/service/ChatRoomService.kt | 167 ++++++++++++++++++ src/main/resources/application.yml | 1 + 6 files changed, 330 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt new file mode 100644 index 0000000..e595c83 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.chat.room.controller + +import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest +import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/room") +class ChatRoomController( + private val chatRoomService: ChatRoomService +) { + + /** + * 채팅방 생성 API + * + * 1. 캐릭터 ID, 유저 ID가 참여 중인 채팅방이 있는지 확인 + * 2. 있으면 채팅방 ID 반환 + * 3. 없으면 외부 API 호출 + * 4. 성공시 외부 API에서 가져오는 sessionId를 포함하여 채팅방 생성 + * 5. 채팅방 참여자로 캐릭터와 유저 추가 + * 6. 채팅방 ID 반환 + * + * @param member 인증된 사용자 + * @param request 채팅방 생성 요청 DTO + * @return 채팅방 ID + */ + @PostMapping("/create") + fun createChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: CreateChatRoomRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.createOrGetChatRoom(member, request.characterId) + ApiResponse.ok(response) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt new file mode 100644 index 0000000..2b53285 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -0,0 +1,46 @@ +package kr.co.vividnext.sodalive.chat.room.dto + +/** + * 채팅방 생성 요청 DTO + */ +data class CreateChatRoomRequest( + val characterId: Long +) + +/** + * 채팅방 생성 응답 DTO + */ +data class CreateChatRoomResponse( + val chatRoomId: Long +) + +/** + * 외부 API 채팅 세션 응답 DTO + */ +data class ExternalChatSessionResponse( + val success: Boolean, + val message: String?, + val data: ExternalChatSessionData? +) + +/** + * 외부 API 채팅 세션 데이터 DTO + */ +data class ExternalChatSessionData( + val sessionId: String, + val userId: String, + val characterId: String, + val character: ExternalCharacterData, + val status: String, + val createdAt: String +) + +/** + * 외부 API 캐릭터 데이터 DTO + */ +data class ExternalCharacterData( + val id: String, + val name: String, + val age: String, + val gender: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt new file mode 100644 index 0000000..77b2a5b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.chat.room.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant +import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CharacterChatParticipantRepository : JpaRepository { + + /** + * 특정 채팅방에 참여 중인 멤버 참여자 찾기 + * + * @param chatRoom 채팅방 + * @param member 멤버 + * @return 채팅방 참여자 (없으면 null) + */ + fun findByChatRoomAndMemberAndIsActiveTrue( + chatRoom: CharacterChatRoom, + member: Member + ): CharacterChatParticipant? + + /** + * 특정 채팅방에 참여 중인 캐릭터 참여자 찾기 + * + * @param chatRoom 채팅방 + * @param character 캐릭터 + * @return 채팅방 참여자 (없으면 null) + */ + fun findByChatRoomAndCharacterAndIsActiveTrue( + chatRoom: CharacterChatRoom, + character: ChatCharacter + ): CharacterChatParticipant? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt new file mode 100644 index 0000000..469fc46 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.chat.room.repository + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.member.Member +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 + +@Repository +interface CharacterChatRoomRepository : JpaRepository { + + /** + * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 + * + * @param member 멤버 + * @param character 캐릭터 + * @return 활성화된 채팅방 (없으면 null) + */ + @Query( + """ + SELECT DISTINCT r FROM CharacterChatRoom r + JOIN r.participants p1 + JOIN r.participants p2 + WHERE p1.member = :member AND p1.isActive = true + AND p2.character = :character AND p2.isActive = true + AND r.isActive = true + """ + ) + fun findActiveChatRoomByMemberAndCharacter( + @Param("member") member: Member, + @Param("character") character: ChatCharacter + ): CharacterChatRoom? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt new file mode 100644 index 0000000..4a8f47f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -0,0 +1,167 @@ +package kr.co.vividnext.sodalive.chat.room.service + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant +import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse +import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse +import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository +import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.client.SimpleClientHttpRequestFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.client.RestTemplate +import java.util.UUID + +@Service +class ChatRoomService( + private val chatRoomRepository: CharacterChatRoomRepository, + private val participantRepository: CharacterChatParticipantRepository, + private val characterService: ChatCharacterService, + + @Value("\${weraser.api-key}") + private val apiKey: String, + + @Value("\${weraser.api-url}") + private val apiUrl: String, + + @Value("\${server.env}") + private val serverEnv: String +) { + + /** + * 채팅방 생성 또는 조회 + * + * @param member 멤버 + * @param characterId 캐릭터 ID + * @return 채팅방 ID + */ + @Transactional + fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse { + // 1. 캐릭터 조회 + val character = characterService.findById(characterId) + ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") + + // 2. 이미 참여 중인 채팅방이 있는지 확인 + val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character) + + // 3. 있으면 채팅방 ID 반환 + if (existingChatRoom != null) { + return CreateChatRoomResponse(chatRoomId = existingChatRoom.id!!) + } + + // 4. 없으면 외부 API 호출하여 세션 생성 + val userId = generateUserId(member.id!!) + val sessionId = callExternalApiForChatSession(userId, character.characterUUID) + + // 5. 채팅방 생성 + val chatRoom = CharacterChatRoom( + sessionId = sessionId, + title = character.name, + isActive = true + ) + val savedChatRoom = chatRoomRepository.save(chatRoom) + + // 6. 채팅방 참여자 추가 (멤버) + val memberParticipant = CharacterChatParticipant( + chatRoom = savedChatRoom, + participantType = ParticipantType.USER, + member = member, + character = null, + isActive = true + ) + participantRepository.save(memberParticipant) + + // 7. 채팅방 참여자 추가 (캐릭터) + val characterParticipant = CharacterChatParticipant( + chatRoom = savedChatRoom, + participantType = ParticipantType.CHARACTER, + member = null, + character = character, + isActive = true + ) + participantRepository.save(characterParticipant) + + // 8. 채팅방 ID 반환 + return CreateChatRoomResponse(chatRoomId = savedChatRoom.id!!) + } + + /** + * 유저 ID 생성 + * "$serverEnv_user_$유저번호"를 UUID로 변환 + * + * @param memberId 멤버 ID + * @return UUID 형태의 유저 ID + */ + private fun generateUserId(memberId: Long): String { + val userIdString = "${serverEnv}_user_$memberId" + return UUID.nameUUIDFromBytes(userIdString.toByteArray()).toString() + } + + /** + * 외부 API 호출하여 채팅 세션 생성 + * + * @param userId 유저 ID (UUID) + * @param characterUUID 캐릭터 UUID + * @return 세션 ID + */ + private fun callExternalApiForChatSession(userId: String, characterUUID: String): String { + try { + val factory = SimpleClientHttpRequestFactory() + factory.setConnectTimeout(20000) // 20초 + factory.setReadTimeout(20000) // 20초 + + val restTemplate = RestTemplate(factory) + + val headers = HttpHeaders() + headers.set("x-api-key", apiKey) + headers.contentType = MediaType.APPLICATION_JSON + + // 요청 바디 생성 - userId와 characterId 전달 + val requestBody = mapOf( + "userId" to userId, + "characterId" to characterUUID + ) + + val httpEntity = HttpEntity(requestBody, headers) + + val response = restTemplate.exchange( + "$apiUrl/api/session", + HttpMethod.POST, + httpEntity, + String::class.java + ) + + // 응답 파싱 + val objectMapper = ObjectMapper() + val apiResponse = objectMapper.readValue(response.body, ExternalChatSessionResponse::class.java) + + // success가 false이면 throw + if (!apiResponse.success) { + throw SodaException(apiResponse.message ?: "채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + + // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 + val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + + if (data.userId != userId && data.characterId != characterUUID && data.status != "active") { + throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + + // 세션 ID 반환 + return data.sessionId + } catch (e: Exception) { + e.printStackTrace() + throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5620001..aa37c6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,6 @@ server: shutdown: graceful + env: ${SERVER_ENV} logging: level: From 4d1f84cc5c644771bafb23b2c3d03dfddf8ab011 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 14:27:25 +0900 Subject: [PATCH 023/119] =?UTF-8?q?feat(chat-room):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20API=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=ED=8E=B8=20=EB=B0=8F=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80/=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=9C=EA=B3=B5\n\?= =?UTF-8?q?n-=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0:=20ApiResponse>=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EB=A1=9C=20=EB=B0=98=ED=99=98\n-=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B3=B4=EB=82=B8=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0\n-=20=EC=83=81=EB=8C=80?= =?UTF-8?q?=EB=B0=A9(=EC=BA=90=EB=A6=AD=ED=84=B0)=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5=20(imageHost/imagePath=20=EC=A1=B0=ED=95=A9=20->=20im?= =?UTF-8?q?ageUrl)\n-=20=EA=B0=80=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=201=EA=B0=9C=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A0=9C=EA=B3=B5=20(=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=2025=EC=9E=90,=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20...=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC)\n-=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=ED=88=AC=EC=98=81=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=EB=A0=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(=EC=B5=9C=EA=B7=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EB=B0=A9=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=20=EC=82=AC=EC=9A=A9)\n-=20=EB=B9=84?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D/=EB=AF=B8=EB=B3=B8=EC=9D=B8=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=82=AC=EC=9A=A9=EC=9E=90:=20=EB=B9=88=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/controller/ChatRoomController.kt | 18 ++++++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 19 +++++++++++ .../CharacterChatMessageRepository.kt | 11 +++++++ .../repository/CharacterChatRoomRepository.kt | 33 ++++++++++++++++--- .../chat/room/service/ChatRoomService.kt | 33 ++++++++++++++++++- 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index e595c83..20c6892 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -42,4 +43,21 @@ class ChatRoomController( val response = chatRoomService.createOrGetChatRoom(member, request.characterId) ApiResponse.ok(response) } + + /** + * 내가 참여 중인 채팅방 목록 조회 API + * - 페이징(기본 20개) + * - 가장 최근 메시지 기준 내림차순 + */ + @GetMapping("/list") + fun listMyChatRooms( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null || member.auth == null) { + ApiResponse.ok(emptyList()) + } else { + val response = chatRoomService.listMyChatRooms(member) + ApiResponse.ok(response) + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 2b53285..030c52a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -14,6 +14,25 @@ data class CreateChatRoomResponse( val chatRoomId: Long ) +/** + * 채팅방 목록 아이템 DTO (API 응답용) + */ +data class ChatRoomListItemDto( + val chatRoomId: Long, + val title: String, + val imageUrl: String, + val lastMessagePreview: String? +) + +/** + * 채팅방 목록 쿼리 DTO (레포지토리 투영용) + */ +data class ChatRoomListQueryDto( + val chatRoomId: Long, + val title: String, + val imagePath: String? +) + /** * 외부 API 채팅 세션 응답 DTO */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt new file mode 100644 index 0000000..a950cac --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.chat.room.repository + +import kr.co.vividnext.sodalive.chat.room.CharacterChatMessage +import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CharacterChatMessageRepository : JpaRepository { + fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt index 469fc46..84d56a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.member.Member import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query @@ -13,10 +14,6 @@ interface CharacterChatRoomRepository : JpaRepository { /** * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 - * - * @param member 멤버 - * @param character 캐릭터 - * @return 활성화된 채팅방 (없으면 null) */ @Query( """ @@ -32,4 +29,32 @@ interface CharacterChatRoomRepository : JpaRepository { @Param("member") member: Member, @Param("character") character: ChatCharacter ): CharacterChatRoom? + + /** + * 멤버가 참여 중인 채팅방을 최근 메시지 시간 순으로 페이징 조회 + * - 메시지가 없으면 방 생성 시간(createdAt)으로 대체 + */ + @Query( + value = """ + SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto( + r.id, + r.title, + pc.character.imagePath + ) + FROM CharacterChatRoom r + JOIN r.participants p + JOIN r.participants pc + LEFT JOIN r.messages m + WHERE p.member = :member + AND p.isActive = true + AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER + AND pc.isActive = true + AND r.isActive = true + GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath + ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC + """ + ) + fun findMemberRoomsOrderByLastMessageDesc( + @Param("member") member: Member + ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 4a8f47f..7983ebb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse +import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatMessageRepository import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository import kr.co.vividnext.sodalive.common.SodaException @@ -26,6 +29,7 @@ import java.util.UUID class ChatRoomService( private val chatRoomRepository: CharacterChatRoomRepository, private val participantRepository: CharacterChatParticipantRepository, + private val messageRepository: CharacterChatMessageRepository, private val characterService: ChatCharacterService, @Value("\${weraser.api-key}") @@ -35,7 +39,10 @@ class ChatRoomService( private val apiUrl: String, @Value("\${server.env}") - private val serverEnv: String + private val serverEnv: String, + + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String ) { /** @@ -164,4 +171,28 @@ class ChatRoomService( throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") } } + + @Transactional(readOnly = true) + fun listMyChatRooms(member: Member): List { + val rooms: List = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc(member) + return rooms.map { q -> + val room = CharacterChatRoom( + sessionId = "", + title = q.title, + isActive = true + ).apply { id = q.chatRoomId } + + val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room) + val preview = latest?.message?.let { msg -> + if (msg.length <= 25) msg else msg.substring(0, 25) + "..." + } + + ChatRoomListItemDto( + chatRoomId = q.chatRoomId, + title = q.title, + imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}", + lastMessagePreview = preview + ) + } + } } From 830e41dfa3576781f53baed80ea976d20d2624b8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 15:15:29 +0900 Subject: [PATCH 024/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=84=B8=EC=85=98=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/controller/ChatRoomController.kt | 18 ++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 30 ++++++++-- .../chat/room/service/ChatRoomService.kt | 59 ++++++++++++++++++- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 20c6892..d36aa54 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member 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.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -60,4 +61,21 @@ class ChatRoomController( ApiResponse.ok(response) } } + + /** + * 세션 상태 조회 API + * - 채팅방 참여 여부 검증 + * - 외부 API로 세션 상태 조회 후 active면 true, 아니면 false 반환 + */ + @GetMapping("/{chatRoomId}/session") + fun getChatSessionStatus( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) + ApiResponse.ok(isActive) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 030c52a..5768d7e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -34,18 +34,20 @@ data class ChatRoomListQueryDto( ) /** - * 외부 API 채팅 세션 응답 DTO + * 외부 API 채팅 세션 생성 응답 DTO */ -data class ExternalChatSessionResponse( +data class ExternalChatSessionCreateResponse( val success: Boolean, val message: String?, - val data: ExternalChatSessionData? + val data: ExternalChatSessionCreateData? ) /** - * 외부 API 채팅 세션 데이터 DTO + * 외부 API 채팅 세션 생성 데이터 DTO + * 공통: sessionId, status + * 생성 전용: userId, characterId, character, createdAt */ -data class ExternalChatSessionData( +data class ExternalChatSessionCreateData( val sessionId: String, val userId: String, val characterId: String, @@ -54,6 +56,24 @@ data class ExternalChatSessionData( val createdAt: String ) +/** + * 외부 API 채팅 세션 조회 응답 DTO + */ +data class ExternalChatSessionGetResponse( + val success: Boolean, + val message: String?, + val data: ExternalChatSessionGetData? +) + +/** + * 외부 API 채팅 세션 조회 데이터 DTO + * 세션 조회에서 사용하는 공통 필드만 포함 + */ +data class ExternalChatSessionGetData( + val sessionId: String, + val status: String +) + /** * 외부 API 캐릭터 데이터 DTO */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 7983ebb..7aebc3b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -8,7 +8,8 @@ import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse -import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse +import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse +import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatMessageRepository import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository @@ -150,7 +151,10 @@ class ChatRoomService( // 응답 파싱 val objectMapper = ObjectMapper() - val apiResponse = objectMapper.readValue(response.body, ExternalChatSessionResponse::class.java) + val apiResponse = objectMapper.readValue( + response.body, + ExternalChatSessionCreateResponse::class.java + ) // success가 false이면 throw if (!apiResponse.success) { @@ -195,4 +199,55 @@ class ChatRoomService( ) } } + + @Transactional(readOnly = true) + fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean { + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + if (participant == null) { + throw SodaException("잘못된 접근입니다") + } + return fetchSessionActive(room.sessionId) + } + + private fun fetchSessionActive(sessionId: String): Boolean { + try { + val factory = SimpleClientHttpRequestFactory() + factory.setConnectTimeout(20000) // 20초 + factory.setReadTimeout(20000) // 20초 + + val restTemplate = RestTemplate(factory) + + val headers = HttpHeaders() + headers.set("x-api-key", apiKey) + + val httpEntity = HttpEntity(null, headers) + + val response = restTemplate.exchange( + "$apiUrl/api/session/$sessionId", + HttpMethod.GET, + httpEntity, + String::class.java + ) + + val objectMapper = ObjectMapper() + val apiResponse = objectMapper.readValue( + response.body, + ExternalChatSessionGetResponse::class.java + ) + + // success가 false이면 throw + if (!apiResponse.success) { + throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + } + + val status = apiResponse.data?.status + return status == "active" + } catch (e: Exception) { + e.printStackTrace() + throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + } + } } From 1509ee0729ff93acfb14b4778db6ecb06d9117d6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 15:48:20 +0900 Subject: [PATCH 025/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=82=98=EA=B0=80=EA=B8=B0=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/room/CharacterChatParticipant.kt | 2 +- .../room/controller/ChatRoomController.kt | 19 +++++ .../CharacterChatParticipantRepository.kt | 18 ++--- .../chat/room/service/ChatRoomService.kt | 74 +++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt index 60e1d2c..fdfc436 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt @@ -29,7 +29,7 @@ class CharacterChatParticipant( @JoinColumn(name = "character_id") val character: ChatCharacter? = null, - val isActive: Boolean = true + var isActive: Boolean = true ) : BaseEntity() { @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) val messages: MutableList = mutableListOf() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index d36aa54..1a02229 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -78,4 +78,23 @@ class ChatRoomController( val isActive = chatRoomService.isMyRoomSessionActive(member, chatRoomId) ApiResponse.ok(isActive) } + + /** + * 채팅방 나가기 API + * - URL에 chatRoomId 포함 + * - 내가 참여 중인지 확인 (아니면 "잘못된 접근입니다") + * - 내 참여자 isActive=false 처리 + * - 내가 마지막 USER였다면 외부 API로 세션 종료 호출 + */ + @PostMapping("/{chatRoomId}/leave") + fun leaveChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + chatRoomService.leaveChatRoom(member, chatRoomId) + ApiResponse.ok(true) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt index 77b2a5b..edd5e60 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt @@ -1,8 +1,8 @@ package kr.co.vividnext.sodalive.chat.room.repository -import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.member.Member import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -12,10 +12,6 @@ interface CharacterChatParticipantRepository : JpaRepository Date: Fri, 8 Aug 2025 16:00:30 +0900 Subject: [PATCH 026/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/controller/ChatRoomController.kt | 19 +++++++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 10 ++++++ .../CharacterChatMessageRepository.kt | 7 ++++ .../chat/room/service/ChatRoomService.kt | 33 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 1a02229..7be88a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping 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.RestController @RestController @@ -62,6 +63,24 @@ class ChatRoomController( } } + /** + * 채팅방 메시지 조회 API + * - 참여 여부 검증(미참여시 "잘못된 접근입니다") + * - messageId가 있으면 해당 ID 이전 20개, 없으면 최신 20개 + */ + @GetMapping("/{chatRoomId}/messages") + fun getChatMessages( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestParam(required = false) messageId: Long? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.getChatMessages(member, chatRoomId, messageId) + ApiResponse.ok(response) + } + /** * 세션 상태 조회 API * - 채팅방 참여 여부 검증 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 5768d7e..60ffe61 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -24,6 +24,16 @@ data class ChatRoomListItemDto( val lastMessagePreview: String? ) +/** + * 채팅방 메시지 아이템 DTO (API 응답용) + */ +data class ChatMessageItemDto( + val messageId: Long, + val message: String, + val profileImageUrl: String, + val mine: Boolean +) + /** * 채팅방 목록 쿼리 DTO (레포지토리 투영용) */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt index a950cac..10a4f2f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt @@ -8,4 +8,11 @@ import org.springframework.stereotype.Repository @Repository interface CharacterChatMessageRepository : JpaRepository { fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? + + fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: CharacterChatRoom): List + + fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + chatRoom: CharacterChatRoom, + id: Long + ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index d887859..2a7878c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse @@ -324,4 +325,36 @@ class ChatRoomService( // 최종 실패 로그 (예외 미전파) log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) } + + @Transactional(readOnly = true) + fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List { + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + if (participant == null) { + throw SodaException("잘못된 접근입니다") + } + + val messages = if (beforeMessageId != null) { + messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId) + } else { + messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room) + } + + return messages.map { msg -> + val sender = msg.participant + val profilePath = when (sender.participantType) { + ParticipantType.USER -> sender.member?.profileImage + ParticipantType.CHARACTER -> sender.character?.imagePath + } + val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + ChatMessageItemDto( + messageId = msg.id!!, + message = msg.message, + profileImageUrl = imageUrl, + mine = sender.member?.id == member.id + ) + } + } } From 4b3463e97c43d15e93023b36c5ae3b03dd25f741 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 16:39:12 +0900 Subject: [PATCH 027/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/controller/ChatRoomController.kt | 19 +++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 41 ++++++ .../CharacterChatParticipantRepository.kt | 8 ++ .../chat/room/service/ChatRoomService.kt | 130 ++++++++++++++++++ 4 files changed, 198 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 7be88a4..6f6f10b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.room.controller import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest +import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException @@ -116,4 +117,22 @@ class ChatRoomController( chatRoomService.leaveChatRoom(member, chatRoomId) ApiResponse.ok(true) } + + /** + * 채팅방 메시지 전송 API + * - 참여 여부 검증(미참여시 "잘못된 접근입니다") + * - 외부 API 호출 (/api/chat, POST) 재시도 최대 3회 + * - 성공 시 내 메시지/캐릭터 메시지 저장 후 캐릭터 메시지 리스트 반환 + */ + @PostMapping("/{chatRoomId}/send") + fun sendMessage( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestBody request: SendChatMessageRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 60ffe61..79bd7cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -93,3 +93,44 @@ data class ExternalCharacterData( val age: String, val gender: String ) + +/** + * 채팅 메시지 전송 요청 DTO + */ +data class SendChatMessageRequest( + val message: String +) + +/** + * 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트) + */ +data class SendChatMessageResponse( + val characterMessages: List +) + +/** + * 외부 API 채팅 전송 응답 DTO + */ +data class ExternalChatSendResponse( + val success: Boolean, + val message: String?, + val data: ExternalChatSendData? +) + +/** + * 외부 API 채팅 전송 데이터 DTO + */ +data class ExternalChatSendData( + val sessionId: String, + val characterResponse: ExternalCharacterMessage +) + +/** + * 외부 API 캐릭터 메시지 DTO + */ +data class ExternalCharacterMessage( + val id: String, + val content: String, + val timestamp: String, + val messageType: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt index edd5e60..3d4f144 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt @@ -18,6 +18,14 @@ interface CharacterChatParticipantRepository : JpaRepository Date: Fri, 8 Aug 2025 16:47:47 +0900 Subject: [PATCH 028/119] =?UTF-8?q?fix(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9,=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80,=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterChatRoom -> ChatRoom - CharacterChatMessage -> ChatMessage - CharacterChatParticipant -> ChatParticipant --- ...CharacterChatMessage.kt => ChatMessage.kt} | 6 ++-- ...rChatParticipant.kt => ChatParticipant.kt} | 6 ++-- .../{CharacterChatRoom.kt => ChatRoom.kt} | 6 ++-- ...Repository.kt => ChatMessageRepository.kt} | 14 ++++----- ...sitory.kt => ChatParticipantRepository.kt} | 16 +++++----- ...oomRepository.kt => ChatRoomRepository.kt} | 10 +++---- .../chat/room/service/ChatRoomService.kt | 30 +++++++++---------- 7 files changed, 44 insertions(+), 44 deletions(-) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/{CharacterChatMessage.kt => ChatMessage.kt} (82%) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/{CharacterChatParticipant.kt => ChatParticipant.kt} (88%) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/{CharacterChatRoom.kt => ChatRoom.kt} (75%) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/{CharacterChatMessageRepository.kt => ChatMessageRepository.kt} (51%) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/{CharacterChatParticipantRepository.kt => ChatParticipantRepository.kt} (67%) rename src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/{CharacterChatRoomRepository.kt => ChatRoomRepository.kt} (88%) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt similarity index 82% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt index 88439df..40bd33f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt @@ -7,16 +7,16 @@ import javax.persistence.JoinColumn import javax.persistence.ManyToOne @Entity -class CharacterChatMessage( +class ChatMessage( val message: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_room_id", nullable = false) - val chatRoom: CharacterChatRoom, + val chatRoom: ChatRoom, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "participant_id", nullable = false) - val participant: CharacterChatParticipant, + val participant: ChatParticipant, val isActive: Boolean = true ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatParticipant.kt similarity index 88% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatParticipant.kt index fdfc436..26d046a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatParticipant.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatParticipant.kt @@ -13,10 +13,10 @@ import javax.persistence.ManyToOne import javax.persistence.OneToMany @Entity -class CharacterChatParticipant( +class ChatParticipant( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_room_id", nullable = false) - val chatRoom: CharacterChatRoom, + val chatRoom: ChatRoom, @Enumerated(EnumType.STRING) val participantType: ParticipantType, @@ -32,7 +32,7 @@ class CharacterChatParticipant( var isActive: Boolean = true ) : BaseEntity() { @OneToMany(mappedBy = "participant", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - val messages: MutableList = mutableListOf() + val messages: MutableList = mutableListOf() } enum class ParticipantType { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt similarity index 75% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt index adea2b5..ff8e9d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/CharacterChatRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt @@ -7,14 +7,14 @@ import javax.persistence.FetchType import javax.persistence.OneToMany @Entity -class CharacterChatRoom( +class ChatRoom( val sessionId: String, val title: String, val isActive: Boolean = true ) : BaseEntity() { @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - val messages: MutableList = mutableListOf() + val messages: MutableList = mutableListOf() @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) - val participants: MutableList = mutableListOf() + val participants: MutableList = mutableListOf() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt similarity index 51% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt index 10a4f2f..de0c5a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt @@ -1,18 +1,18 @@ package kr.co.vividnext.sodalive.chat.room.repository -import kr.co.vividnext.sodalive.chat.room.CharacterChatMessage -import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatRoom import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface CharacterChatMessageRepository : JpaRepository { - fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: CharacterChatRoom): CharacterChatMessage? +interface ChatMessageRepository : JpaRepository { + fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage? - fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: CharacterChatRoom): List + fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( - chatRoom: CharacterChatRoom, + chatRoom: ChatRoom, id: Long - ): List + ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatParticipantRepository.kt similarity index 67% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatParticipantRepository.kt index 3d4f144..f6808ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatParticipantRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatParticipantRepository.kt @@ -1,36 +1,36 @@ package kr.co.vividnext.sodalive.chat.room.repository -import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant -import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.member.Member import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface CharacterChatParticipantRepository : JpaRepository { +interface ChatParticipantRepository : JpaRepository { /** * 특정 채팅방에 참여 중인 멤버 참여자 찾기 */ fun findByChatRoomAndMemberAndIsActiveTrue( - chatRoom: CharacterChatRoom, + chatRoom: ChatRoom, member: Member - ): CharacterChatParticipant? + ): ChatParticipant? /** * 특정 채팅방에 특정 타입(CHARACTER/USER)으로 활성 상태인 참여자 찾기 */ fun findByChatRoomAndParticipantTypeAndIsActiveTrue( - chatRoom: CharacterChatRoom, + chatRoom: ChatRoom, participantType: ParticipantType - ): CharacterChatParticipant? + ): ChatParticipant? /** * 특정 채팅방의 활성 USER 참여자 수 */ fun countByChatRoomAndParticipantTypeAndIsActiveTrue( - chatRoom: CharacterChatRoom, + chatRoom: ChatRoom, participantType: ParticipantType ): Long } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt similarity index 88% rename from src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt rename to src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 84d56a7..c840948 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/CharacterChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -1,7 +1,7 @@ package kr.co.vividnext.sodalive.chat.room.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.member.Member import org.springframework.data.jpa.repository.JpaRepository @@ -10,14 +10,14 @@ import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository @Repository -interface CharacterChatRoomRepository : JpaRepository { +interface ChatRoomRepository : JpaRepository { /** * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 */ @Query( """ - SELECT DISTINCT r FROM CharacterChatRoom r + SELECT DISTINCT r FROM ChatRoom r JOIN r.participants p1 JOIN r.participants p2 WHERE p1.member = :member AND p1.isActive = true @@ -28,7 +28,7 @@ interface CharacterChatRoomRepository : JpaRepository { fun findActiveChatRoomByMemberAndCharacter( @Param("member") member: Member, @Param("character") character: ChatCharacter - ): CharacterChatRoom? + ): ChatRoom? /** * 멤버가 참여 중인 채팅방을 최근 메시지 시간 순으로 페이징 조회 @@ -41,7 +41,7 @@ interface CharacterChatRoomRepository : JpaRepository { r.title, pc.character.imagePath ) - FROM CharacterChatRoom r + FROM ChatRoom r JOIN r.participants p JOIN r.participants pc LEFT JOIN r.messages m diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 67d274f..661f73a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -2,9 +2,9 @@ package kr.co.vividnext.sodalive.chat.room.service import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService -import kr.co.vividnext.sodalive.chat.room.CharacterChatMessage -import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant -import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom +import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatParticipant +import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto @@ -14,9 +14,9 @@ import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse -import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatMessageRepository -import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository -import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository +import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository +import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import org.slf4j.LoggerFactory @@ -33,9 +33,9 @@ import java.util.UUID @Service class ChatRoomService( - private val chatRoomRepository: CharacterChatRoomRepository, - private val participantRepository: CharacterChatParticipantRepository, - private val messageRepository: CharacterChatMessageRepository, + private val chatRoomRepository: ChatRoomRepository, + private val participantRepository: ChatParticipantRepository, + private val messageRepository: ChatMessageRepository, private val characterService: ChatCharacterService, @Value("\${weraser.api-key}") @@ -78,7 +78,7 @@ class ChatRoomService( val sessionId = callExternalApiForChatSession(userId, character.characterUUID) // 5. 채팅방 생성 - val chatRoom = CharacterChatRoom( + val chatRoom = ChatRoom( sessionId = sessionId, title = character.name, isActive = true @@ -86,7 +86,7 @@ class ChatRoomService( val savedChatRoom = chatRoomRepository.save(chatRoom) // 6. 채팅방 참여자 추가 (멤버) - val memberParticipant = CharacterChatParticipant( + val memberParticipant = ChatParticipant( chatRoom = savedChatRoom, participantType = ParticipantType.USER, member = member, @@ -96,7 +96,7 @@ class ChatRoomService( participantRepository.save(memberParticipant) // 7. 채팅방 참여자 추가 (캐릭터) - val characterParticipant = CharacterChatParticipant( + val characterParticipant = ChatParticipant( chatRoom = savedChatRoom, participantType = ParticipantType.CHARACTER, member = null, @@ -186,7 +186,7 @@ class ChatRoomService( fun listMyChatRooms(member: Member): List { val rooms: List = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc(member) return rooms.map { q -> - val room = CharacterChatRoom( + val room = ChatRoom( sessionId = "", title = q.title, isActive = true @@ -388,7 +388,7 @@ class ChatRoomService( val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) // 6) 내 메시지 저장 - val myMsgEntity = CharacterChatMessage( + val myMsgEntity = ChatMessage( message = message, chatRoom = room, participant = myParticipant, @@ -397,7 +397,7 @@ class ChatRoomService( messageRepository.save(myMsgEntity) // 7) 캐릭터 메시지 저장 - val characterMsgEntity = CharacterChatMessage( + val characterMsgEntity = ChatMessage( message = characterReply, chatRoom = room, participant = characterParticipant, From ebad3b31b7e741be64daf48ece52a2d96d7413ca Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 16:52:30 +0900 Subject: [PATCH 029/119] =?UTF-8?q?fix(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 메시지이면 전송하지 않고 반환 --- .../sodalive/chat/room/controller/ChatRoomController.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 6f6f10b..9623f3f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -133,6 +133,10 @@ class ChatRoomController( if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message)) + if (request.message.isBlank()) { + ApiResponse.error() + } else { + ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message)) + } } } From 5d1c5fcc44948707e90fb3f9dee4496d2452d799 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 17:11:38 +0900 Subject: [PATCH 030/119] =?UTF-8?q?fix(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지 DB 타입을 TEXT로 변경 --- .../kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt index 40bd33f..1013a3e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.room import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -8,6 +9,7 @@ import javax.persistence.ManyToOne @Entity class ChatMessage( + @Column(columnDefinition = "TEXT", nullable = false) val message: String, @ManyToOne(fetch = FetchType.LAZY) From b819df96561f9e8a353419c626ba510e841bee2b Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 17:31:21 +0900 Subject: [PATCH 031/119] =?UTF-8?q?feat(securityConfig):=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20API=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=EB=8F=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/chat/list --- .../kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt | 1 + 1 file changed, 1 insertion(+) 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 78fb478..de3f665 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -94,6 +94,7 @@ class SecurityConfig( .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() + .antMatchers(HttpMethod.GET, "/api/chat/list").permitAll() .anyRequest().authenticated() .and() .build() From a6a01aaa37d0420e5c78420d2bacdc04d393c872 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 21:19:37 +0900 Subject: [PATCH 032/119] =?UTF-8?q?fix(banner):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EA=B2=80=EC=83=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색 결과에 imageHost와 imagePath 사이에 / 추가 --- .../admin/chat/character/dto/ChatCharacterSearchResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt index 4678747..cade24a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt @@ -22,7 +22,7 @@ data class ChatCharacterSearchResponse( name = character.name, description = character.description, mbti = character.mbti, - imagePath = character.imagePath?.let { "$imageHost$it" }, + imagePath = character.imagePath?.let { "$imageHost/$it" }, tags = tags ) } From 5129400a291f31b53d2e843e264aa8b8ca3fe881 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 8 Aug 2025 21:46:47 +0900 Subject: [PATCH 033/119] =?UTF-8?q?fix(banner):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paging 관련 데이터 중 totalCount만 반환 --- .../admin/chat/AdminChatBannerController.kt | 9 +++++++-- .../dto/ChatCharacterSearchResponse.kt | 20 +++++++++---------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 2c4b4b0..d4831fb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.chat import com.amazonaws.services.s3.model.ObjectMetadata +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterSearchListPageResponse import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest @@ -27,7 +28,7 @@ import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile @RestController -@RequestMapping("/api/admin/chat/banner") +@RequestMapping("/admin/chat/banner") @PreAuthorize("hasRole('ADMIN')") class AdminChatBannerController( private val bannerService: ChatCharacterBannerService, @@ -91,7 +92,11 @@ class AdminChatBannerController( @RequestParam(defaultValue = "20") size: Int ) = run { val pageable = adminCharacterService.createDefaultPageRequest(page, size) - val response = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost) + val pageResult = adminCharacterService.searchCharacters(searchTerm, pageable, imageHost) + val response = ChatCharacterSearchListPageResponse( + totalCount = pageResult.totalElements, + content = pageResult.content + ) ApiResponse.ok(response) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt index cade24a..9c93850 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterSearchResponse.kt @@ -8,23 +8,23 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter data class ChatCharacterSearchResponse( val id: Long, val name: String, - val description: String, - val mbti: String?, - val imagePath: String?, - val tags: List + val imagePath: String? ) { companion object { fun from(character: ChatCharacter, imageHost: String): ChatCharacterSearchResponse { - val tags = character.tagMappings.map { it.tag.tag } - return ChatCharacterSearchResponse( id = character.id!!, name = character.name, - description = character.description, - mbti = character.mbti, - imagePath = character.imagePath?.let { "$imageHost/$it" }, - tags = tags + imagePath = character.imagePath?.let { "$imageHost/$it" } ) } } } + +/** + * 캐릭터 검색 결과 페이지 응답 DTO + */ +data class ChatCharacterSearchListPageResponse( + val totalCount: Long, + val content: List +) From 735f1e26df9bb875d6132789ca64e9623bc358e8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 11 Aug 2025 11:33:35 +0900 Subject: [PATCH 034/119] =?UTF-8?q?feat(chat-character):=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EB=8C=80=ED=99=94=ED=95=9C=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A9=94=EC=9D=B8=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 왜: 기존에는 채팅방 미구현으로 최근 대화 리스트를 빈 배열로 응답했음. 채팅방/메시지 기능이 준비됨에 따라 실제 최근 대화 캐릭터를 노출해야 함. 무엇: - repository: findRecentCharactersByMember JPA 쿼리 추가 (채팅방/참여자/메시지 조인, 최신 메시지 기준 정렬) - service: getRecentCharacters(member, limit) 구현 (member null 처리 및 페이징 적용) - controller: /api/chat/character/main에서 인증 사용자 기준 최근 캐릭터 최대 10개 반환 --- .../controller/ChatCharacterController.kt | 4 +-- .../repository/ChatCharacterRepository.kt | 26 +++++++++++++++++++ .../character/service/ChatCharacterService.kt | 8 +++--- .../sodalive/configs/SecurityConfig.kt | 2 +- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 5bee186..20d1e75 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -45,8 +45,8 @@ class ChatCharacterController( ) } - // 최근 대화한 캐릭터 조회 (현재는 빈 리스트) - val recentCharacters = service.getRecentCharacters() + // 최근 대화한 캐릭터 조회 (회원별 최근 순으로 최대 10개) + val recentCharacters = service.getRecentCharacters(member, 10) .map { RecentCharacter( characterId = it.id!!, 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 59a617c..ee760aa 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 @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -40,4 +41,29 @@ interface ChatCharacterRepository : JpaRepository { @Param("searchTerm") searchTerm: String, pageable: Pageable ): Page + + /** + * 멤버가 최근에 대화한 캐릭터 목록을 반환 (최신 메시지 시간 기준 내림차순) + */ + @Query( + value = """ + SELECT c FROM ChatRoom r + JOIN r.participants pu + JOIN r.participants pc + JOIN pc.character c + LEFT JOIN r.messages m + WHERE pu.member = :member + AND pu.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.USER + AND pu.isActive = true + AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER + AND pc.isActive = true + AND r.isActive = true + GROUP BY c.id + ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC + """ + ) + fun findRecentCharactersByMember( + @Param("member") member: Member, + pageable: Pageable + ): List } 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 0f9f626..8ddce61 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 @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,12 +27,11 @@ class ChatCharacterService( /** * 최근에 대화한 캐릭터 목록 조회 - * 현재는 채팅방 구현 전이므로 빈 리스트 반환 */ @Transactional(readOnly = true) - fun getRecentCharacters(): List { - // 채팅방 구현 전이므로 빈 리스트 반환 - return emptyList() + fun getRecentCharacters(member: Member?, limit: Int = 10): List { + if (member == null) return emptyList() + return chatCharacterRepository.findRecentCharactersByMember(member, PageRequest.of(0, limit)) } /** 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 de3f665..39142aa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -94,7 +94,7 @@ class SecurityConfig( .antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() - .antMatchers(HttpMethod.GET, "/api/chat/list").permitAll() + .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .anyRequest().authenticated() .and() .build() From c525ec03307114f3b5f9ee6437c8f63cfbd26666 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 11 Aug 2025 14:26:00 +0900 Subject: [PATCH 035/119] =?UTF-8?q?feat(chat):=20=EB=82=B4=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20page=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository에 Pageable 인자로 전달하여 DB 레벨 limit/offset 적용 - Service에서 PageRequest.of(page, 20)로 20개 페이지 처리 고정 - Controller /api/chat/room/list에 page 요청 파라미터 추가 및 전달 왜: 참여 중인 채팅방 목록이 페이징되지 않아 20개 단위로 최신 메시지 기준 내림차순 페이징 처리 필요 --- .../chat/room/controller/ChatRoomController.kt | 5 +++-- .../chat/room/repository/ChatRoomRepository.kt | 4 +++- .../sodalive/chat/room/service/ChatRoomService.kt | 10 ++++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 9623f3f..1fbde14 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -54,12 +54,13 @@ class ChatRoomController( */ @GetMapping("/list") fun listMyChatRooms( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestParam(defaultValue = "0") page: Int ) = run { if (member == null || member.auth == null) { ApiResponse.ok(emptyList()) } else { - val response = chatRoomService.listMyChatRooms(member) + val response = chatRoomService.listMyChatRooms(member, page) ApiResponse.ok(response) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index c840948..0b719a0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.member.Member +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 @@ -55,6 +56,7 @@ interface ChatRoomRepository : JpaRepository { """ ) fun findMemberRoomsOrderByLastMessageDesc( - @Param("member") member: Member + @Param("member") member: Member, + pageable: Pageable ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 661f73a..42d24e0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod @@ -183,8 +184,13 @@ class ChatRoomService( } @Transactional(readOnly = true) - fun listMyChatRooms(member: Member): List { - val rooms: List = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc(member) + fun listMyChatRooms(member: Member, page: Int): List { + // 기본 페이지당 20개 고정 + val pageable = PageRequest.of(if (page < 0) 0 else page, 20) + val rooms: List = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc( + member, + pageable + ) return rooms.map { q -> val room = ChatRoom( sessionId = "", From 2dc5a29220f0ce0797d34f695536f1aab79526ee Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 02:13:46 +0900 Subject: [PATCH 036/119] =?UTF-8?q?feat(chat-character):=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20name=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=93=B1=EB=A1=9D/=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F?= =?UTF-8?q?=20DTO=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관계 스키마를 name, relationShip 구조로 일원화 - Admin/사용자 컨트롤러 조회 응답에서 관계를 객체로 반환하도록 수정 - 등록/수정 요청 DTO에 ChatCharacterRelationshipRequest(name, relationShip) 추가 - 서비스 계층 create/update/add 메소드 시그니처 및 매핑 로직 업데이트 - description 한 줄 소개 사용 전제 하의 관련 사용부 점검(엔티티 컬럼 구성은 기존 유지) --- .../chat/character/AdminChatCharacterController.kt | 2 +- .../character/dto/ChatCharacterDetailResponse.kt | 9 +++++++-- .../admin/chat/character/dto/ChatCharacterDto.kt | 9 +++++++-- .../sodalive/chat/character/ChatCharacter.kt | 7 +++---- .../chat/character/ChatCharacterRelationship.kt | 1 + .../character/controller/ChatCharacterController.kt | 3 ++- .../chat/character/dto/CharacterDetailResponse.kt | 7 ++++++- .../chat/character/service/ChatCharacterService.kt | 12 ++++++------ 8 files changed, 33 insertions(+), 17 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 95f848c..498688c 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 @@ -126,7 +126,7 @@ class AdminChatCharacterController( memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, personalities = request.personalities.map { Pair(it.trait, 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 설정 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 e71b9fe..308acea 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 @@ -20,7 +20,7 @@ data class ChatCharacterDetailResponse( val hobbies: List, val values: List, val goals: List, - val relationships: List, + val relationships: List, val personalities: List, val backgrounds: List, val memories: List @@ -51,7 +51,7 @@ data class ChatCharacterDetailResponse( hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, values = chatCharacter.valueMappings.map { it.value.value }, 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 { PersonalityResponse(it.trait, it.description) }, @@ -81,3 +81,8 @@ data class MemoryResponse( val content: String, val emotion: String ) + +data class RelationshipResponse( + val name: String, + val relationShip: String +) 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 717bcd6..773d814 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 @@ -18,6 +18,11 @@ data class ChatCharacterMemoryRequest( @JsonProperty("emotion") val emotion: String ) +data class ChatCharacterRelationshipRequest( + @JsonProperty("name") val name: String, + @JsonProperty("relationShip") val relationShip: String +) + data class ChatCharacterRegisterRequest( @JsonProperty("name") val name: String, @JsonProperty("systemPrompt") val systemPrompt: String, @@ -32,7 +37,7 @@ data class ChatCharacterRegisterRequest( @JsonProperty("hobbies") val hobbies: List = emptyList(), @JsonProperty("values") val values: List = emptyList(), @JsonProperty("goals") val goals: List = emptyList(), - @JsonProperty("relationships") val relationships: List = emptyList(), + @JsonProperty("relationships") val relationships: List = emptyList(), @JsonProperty("personalities") val personalities: List = emptyList(), @JsonProperty("backgrounds") val backgrounds: List = emptyList(), @JsonProperty("memories") val memories: List = emptyList() @@ -64,7 +69,7 @@ data class ChatCharacterUpdateRequest( @JsonProperty("hobbies") val hobbies: List? = null, @JsonProperty("values") val values: List? = null, @JsonProperty("goals") val goals: List? = null, - @JsonProperty("relationships") val relationships: List? = null, + @JsonProperty("relationships") val relationships: List? = null, @JsonProperty("personalities") val personalities: List? = null, @JsonProperty("backgrounds") val backgrounds: List? = null, @JsonProperty("memories") val memories: List? = null 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 a5a3e86..2dfbf99 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 @@ -14,8 +14,7 @@ class ChatCharacter( // 캐릭터 이름 (API 키 내에서 유일해야 함) var name: String, - // 캐릭터 설명 - @Column(columnDefinition = "TEXT", nullable = false) + // 캐릭터 한 줄 소개 var description: String, // AI 시스템 프롬프트 @@ -113,8 +112,8 @@ class ChatCharacter( } // 관계 추가 헬퍼 메소드 - fun addRelationship(relationShip: String) { - val relationship = ChatCharacterRelationship(relationShip, this) + fun addRelationship(name: String, relationShip: String) { + val relationship = ChatCharacterRelationship(name, relationShip, this) relationships.add(relationship) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt index ba3932c..d0b6e2c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt @@ -12,6 +12,7 @@ import javax.persistence.ManyToOne @Entity class ChatCharacterRelationship( + var name: String, val relationShip: String, @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 20d1e75..5503bb8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -7,6 +7,7 @@ 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.CharacterMemoryResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse +import kr.co.vividnext.sodalive.chat.character.dto.CharacterRelationshipResponse import kr.co.vividnext.sodalive.chat.character.dto.CurationSection import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService @@ -137,7 +138,7 @@ class ChatCharacterController( ) } - val relationships = character.relationships.map { it.relationShip } + val relationships = character.relationships.map { CharacterRelationshipResponse(it.name, it.relationShip) } // 응답 생성 ApiResponse.ok( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index d9f2492..d093ca3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -14,7 +14,7 @@ data class CharacterDetailResponse( val memories: List = emptyList(), val personalities: List = emptyList(), val backgrounds: List = emptyList(), - val relationships: List = emptyList(), + val relationships: List = emptyList(), val tags: List = emptyList(), val values: List = emptyList(), val hobbies: List = emptyList(), @@ -36,3 +36,8 @@ data class CharacterBackgroundResponse( val topic: String, val description: String ) + +data class CharacterRelationshipResponse( + val name: String, + val relationShip: String +) 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 8ddce61..2e567c0 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 @@ -266,8 +266,8 @@ class ChatCharacterService( * 캐릭터에 관계 추가 */ @Transactional - fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, relationShip: String) { - chatCharacter.addRelationship(relationShip) + fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, name: String, relationShip: String) { + chatCharacter.addRelationship(name, relationShip) saveChatCharacter(chatCharacter) } @@ -293,7 +293,7 @@ class ChatCharacterService( memories: List> = emptyList(), personalities: List> = emptyList(), backgrounds: List> = emptyList(), - relationships: List = emptyList() + relationships: List> = emptyList() ): ChatCharacter { val chatCharacter = createChatCharacter( characterUUID, name, description, systemPrompt, age, gender, mbti, @@ -313,8 +313,8 @@ class ChatCharacterService( chatCharacter.addBackground(topic, description) } - relationships.forEach { relationShip -> - chatCharacter.addRelationship(relationShip) + relationships.forEach { (name, relationShip) -> + chatCharacter.addRelationship(name, relationShip) } return saveChatCharacter(chatCharacter) @@ -412,7 +412,7 @@ class ChatCharacterService( if (request.relationships != null) { chatCharacter.relationships.clear() request.relationships.forEach { relationship -> - chatCharacter.addRelationship(relationship) + chatCharacter.addRelationship(relationship.name, relationship.relationShip) } } From afb003c3970d3089c27d4e7f53613d8c4fc2fa51 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 02:58:26 +0900 Subject: [PATCH 037/119] =?UTF-8?q?feat(chat-character):=20=EC=9B=90?= =?UTF-8?q?=EC=9E=91/=EC=9B=90=EC=9E=91=20=EB=A7=81=ED=81=AC/=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9C=A0=ED=98=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=99=B8=EB=B6=80=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 비활성화 이름 처리 로직 유지 --- .../character/AdminChatCharacterController.kt | 41 +++++++++++++++++-- .../chat/character/dto/ChatCharacterDto.kt | 6 +++ .../sodalive/chat/character/ChatCharacter.kt | 20 +++++++++ .../character/service/ChatCharacterService.kt | 36 ++++++++++++++-- 4 files changed, 96 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 498688c..6cb49ca 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 @@ -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.service.AdminChatCharacterService 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.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException @@ -119,6 +120,12 @@ class AdminChatCharacterController( speechPattern = request.speechPattern, speechStyle = request.speechStyle, 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, values = request.values, hobbies = request.hobbies, @@ -152,7 +159,27 @@ class AdminChatCharacterController( headers.set("x-api-key", apiKey) // 실제 API 키로 대체 필요 headers.contentType = MediaType.APPLICATION_JSON - val httpEntity = HttpEntity(request, headers) + // 외부 API에 전달하지 않을 필드(originalTitle, originalLink, characterType)를 제외하고 바디 구성 + val body = mutableMapOf() + 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( "$apiUrl/api/characters", @@ -223,16 +250,22 @@ class AdminChatCharacterController( val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java) // 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 - val hasChangedData = hasChanges(request) + val hasChangedData = hasChanges(request) // 외부 API 대상으로의 변경 여부(3가지 필드 제외) // 3. 이미지 있는지 확인 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("변경된 데이터가 없습니다.") } - // 변경된 데이터가 있으면 외부 API 호출 + // 외부 API로 전달할 변경이 있을 때만 외부 API 호출(3가지 필드만 변경된 경우는 호출하지 않음) if (hasChangedData) { val chatCharacter = service.findById(request.id) ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") 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 773d814..7cdf9df 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 @@ -33,6 +33,9 @@ data class ChatCharacterRegisterRequest( @JsonProperty("speechPattern") val speechPattern: String?, @JsonProperty("speechStyle") val speechStyle: 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 = emptyList(), @JsonProperty("hobbies") val hobbies: List = emptyList(), @JsonProperty("values") val values: List = emptyList(), @@ -64,6 +67,9 @@ data class ChatCharacterUpdateRequest( @JsonProperty("speechPattern") val speechPattern: String? = null, @JsonProperty("speechStyle") val speechStyle: 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("tags") val tags: List? = null, @JsonProperty("hobbies") val hobbies: List? = null, 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 2dfbf99..72f7caf 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 @@ -4,6 +4,8 @@ import kr.co.vividnext.sodalive.common.BaseEntity import javax.persistence.CascadeType import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated import javax.persistence.FetchType import javax.persistence.OneToMany @@ -41,6 +43,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, + + // 캐릭터 유형 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var characterType: CharacterType = CharacterType.CHARACTER, + var isActive: Boolean = true ) : BaseEntity() { var imagePath: String? = null @@ -117,3 +132,8 @@ class ChatCharacter( relationships.add(relationship) } } + +enum class CharacterType { + CLONE, + CHARACTER +} 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 2e567c0..4732670 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 @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.service 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.ChatCharacterGoal import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby @@ -208,6 +209,9 @@ class ChatCharacterService( speechPattern: String? = null, speechStyle: String? = null, appearance: String? = null, + originalTitle: String? = null, + originalLink: String? = null, + characterType: CharacterType = CharacterType.CHARACTER, tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -223,7 +227,10 @@ class ChatCharacterService( mbti = mbti, speechPattern = speechPattern, speechStyle = speechStyle, - appearance = appearance + appearance = appearance, + originalTitle = originalTitle, + originalLink = originalLink, + characterType = characterType ) // 관련 엔티티 연결 @@ -286,6 +293,9 @@ class ChatCharacterService( speechPattern: String? = null, speechStyle: String? = null, appearance: String? = null, + originalTitle: String? = null, + originalLink: String? = null, + characterType: CharacterType = CharacterType.CHARACTER, tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -296,8 +306,23 @@ class ChatCharacterService( relationships: List> = emptyList() ): ChatCharacter { val chatCharacter = createChatCharacter( - characterUUID, name, description, systemPrompt, age, gender, mbti, - speechPattern, speechStyle, appearance, tags, values, hobbies, goals + characterUUID = characterUUID, + 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 ) // 추가 정보 설정 @@ -365,6 +390,11 @@ class ChatCharacterService( request.speechPattern?.let { chatCharacter.speechPattern = it } request.speechStyle?.let { chatCharacter.speechStyle = 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에서 변경된 데이터만 업데이트 if (request.tags != null) { From 423cbe7315961420814aa933c17667e927884ad3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 03:16:29 +0900 Subject: [PATCH 038/119] =?UTF-8?q?feat(chat-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EA=B0=84?= =?UTF-8?q?=EC=86=8C=ED=99=94=20=EB=B0=8F=20=ED=83=9C=EA=B7=B8=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=20=EA=B7=9C=EC=B9=99=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterDetailResponse에서 불필요 필드 제거 - 제거: age, gender, speechPattern, speechStyle, appearance, memories, relationships, values, hobbies, goals - 성격(personalities), 배경(backgrounds)을 각각 첫 번째 항목 1개만 반환하도록 변경 - 단일 객체(Optional)로 응답: CharacterPersonalityResponse?, CharacterBackgroundResponse? - 태그 포맷 규칙 적용 - 태그에 # 프리픽스가 없으면 붙이고, 공백으로 연결하여 단일 문자열로 반환 - Controller 로직 정리 - 불필요 매핑 제거 및 DTO 스키마 변경에 맞춘 변환 로직 반영 --- .../controller/ChatCharacterController.kt | 43 +++++-------------- .../character/dto/CharacterDetailResponse.kt | 27 ++---------- 2 files changed, 13 insertions(+), 57 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 5503bb8..a0ffc0f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -5,9 +5,7 @@ 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.CharacterDetailResponse 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.CharacterRelationshipResponse import kr.co.vividnext.sodalive.chat.character.dto.CurationSection import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService @@ -109,58 +107,37 @@ class ChatCharacterController( val character = service.getCharacterDetail(characterId) ?: throw SodaException("캐릭터를 찾을 수 없습니다.") - // 태그, 가치관, 취미, 목표 추출 - val tags = character.tagMappings.map { it.tag.tag } - val values = character.valueMappings.map { it.value.value } - val hobbies = character.hobbyMappings.map { it.hobby.hobby } - val goals = character.goalMappings.map { it.goal.goal } + // 태그 가공: # prefix 규칙 적용 후 공백으로 연결 + val tags = character.tagMappings + .map { it.tag.tag } + .joinToString(" ") { if (it.startsWith("#")) it else "#$it" } - // 메모리, 성격, 배경, 관계 변환 - val memories = character.memories.map { - CharacterMemoryResponse( - title = it.title, - content = it.content, - emotion = it.emotion - ) - } - - val personalities = character.personalities.map { + // 성격, 배경: 각각 첫 번째 항목만 선택 + val personality: CharacterPersonalityResponse? = character.personalities.firstOrNull()?.let { CharacterPersonalityResponse( trait = it.trait, description = it.description ) } - val backgrounds = character.backgrounds.map { + val background: CharacterBackgroundResponse? = character.backgrounds.firstOrNull()?.let { CharacterBackgroundResponse( topic = it.topic, description = it.description ) } - val relationships = character.relationships.map { CharacterRelationshipResponse(it.name, it.relationShip) } - // 응답 생성 ApiResponse.ok( CharacterDetailResponse( characterId = character.id!!, name = character.name, description = character.description, - age = character.age, - gender = character.gender, mbti = character.mbti, - speechPattern = character.speechPattern, - speechStyle = character.speechStyle, - appearance = character.appearance, imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", - memories = memories, - personalities = personalities, - backgrounds = backgrounds, - relationships = relationships, - tags = tags, - values = values, - hobbies = hobbies, - goals = goals + personalities = personality, + backgrounds = background, + tags = tags ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index d093ca3..f8ba56f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -4,27 +4,11 @@ data class CharacterDetailResponse( val characterId: Long, val name: String, val description: String, - val age: Int?, - val gender: String?, val mbti: String?, - val speechPattern: String?, - val speechStyle: String?, - val appearance: String?, val imageUrl: String, - val memories: List = emptyList(), - val personalities: List = emptyList(), - val backgrounds: List = emptyList(), - val relationships: List = emptyList(), - val tags: List = emptyList(), - val values: List = emptyList(), - val hobbies: List = emptyList(), - val goals: List = emptyList() -) - -data class CharacterMemoryResponse( - val title: String, - val content: String, - val emotion: String + val personalities: CharacterPersonalityResponse?, + val backgrounds: CharacterBackgroundResponse?, + val tags: String ) data class CharacterPersonalityResponse( @@ -36,8 +20,3 @@ data class CharacterBackgroundResponse( val topic: String, val description: String ) - -data class CharacterRelationshipResponse( - val name: String, - val relationShip: String -) From 01ef738d31df56600167e1c7256847fefd03d924 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 03:47:48 +0900 Subject: [PATCH 039/119] =?UTF-8?q?feat(chat-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=E2=80=98?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=BA=90=EB=A6=AD=ED=84=B0=E2=80=99=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 페이지 정보 강화 및 탐색성 향상을 위해 응답 필드를 확장 - CharacterDetailResponse에 originalTitle, originalLink, characterType, others 추가 - OtherCharacter DTO 추가 (characterId, name, imageUrl, tags) - 공유 태그 기반으로 현재 캐릭터를 제외한 랜덤 10개 캐릭터 조회 JPA 쿼리 추가 - ChatCharacterRepository.findRandomBySharedTags(@Query, RAND 정렬, 페이징) - 서비스 계층에 getOtherCharactersBySharedTags 추가 및 태그 지연 로딩 초기화 - 컨트롤러에서: - others 리스트를 조회/매핑하여 응답에 포함 - originalTitle, originalLink, characterType을 응답에 포함 --- .../controller/ChatCharacterController.kt | 21 ++++++++++++++++++- .../character/dto/CharacterDetailResponse.kt | 13 ++++++++++++ .../repository/ChatCharacterRepository.kt | 21 +++++++++++++++++++ .../character/service/ChatCharacterService.kt | 14 +++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index a0ffc0f..b3653fe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -7,6 +7,7 @@ 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.CharacterPersonalityResponse 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.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService @@ -127,6 +128,20 @@ class ChatCharacterController( ) } + // 다른 캐릭터 조회 (태그 기반, 랜덤 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( CharacterDetailResponse( @@ -137,7 +152,11 @@ class ChatCharacterController( imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", personalities = personality, backgrounds = background, - tags = tags + tags = tags, + originalTitle = character.originalTitle, + originalLink = character.originalLink, + characterType = character.characterType, + others = others ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index f8ba56f..cb3f90f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.dto +import kr.co.vividnext.sodalive.chat.character.CharacterType + data class CharacterDetailResponse( val characterId: Long, val name: String, @@ -8,6 +10,17 @@ data class CharacterDetailResponse( val imageUrl: String, val personalities: CharacterPersonalityResponse?, val backgrounds: CharacterBackgroundResponse?, + val tags: String, + val originalTitle: String?, + val originalLink: String?, + val characterType: CharacterType, + val others: List +) + +data class OtherCharacter( + val characterId: Long, + val name: String, + val imageUrl: String, val tags: String ) 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 ee760aa..bcc19fb 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 @@ -66,4 +66,25 @@ interface ChatCharacterRepository : JpaRepository { @Param("member") member: Member, pageable: Pageable ): List + + /** + * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) + */ + @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 } 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 4732670..73ac088 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 @@ -53,6 +53,20 @@ class ChatCharacterService( return chatCharacterRepository.findByIsActiveTrueOrderByCreatedAtDesc(PageRequest.of(0, limit)) } + /** + * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) + */ + @Transactional(readOnly = true) + fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List { + val others = chatCharacterRepository.findRandomBySharedTags( + characterId, + PageRequest.of(0, limit) + ) + // 태그 초기화 (지연 로딩 문제 방지) + others.forEach { it.tagMappings.size } + return others + } + /** * 태그를 찾거나 생성하여 캐릭터에 연결 */ From 00c617ec2e3fcac5743261da9bab13d7f948d624 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 16:45:25 +0900 Subject: [PATCH 040/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20characterType=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/dto/ChatCharacterDetailResponse.kt | 2 ++ 1 file changed, 2 insertions(+) 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 308acea..ce7a21b 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 @@ -9,6 +9,7 @@ data class ChatCharacterDetailResponse( val imageUrl: String?, val description: String, val systemPrompt: String, + val characterType: String, val age: Int?, val gender: String?, val mbti: String?, @@ -40,6 +41,7 @@ data class ChatCharacterDetailResponse( imageUrl = fullImagePath, description = chatCharacter.description, systemPrompt = chatCharacter.systemPrompt, + characterType = chatCharacter.characterType.name, age = chatCharacter.age, gender = chatCharacter.gender, mbti = chatCharacter.mbti, From 2965b8fea06ad2d0f848704b0e7a589b3ffe25f5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 17:01:51 +0900 Subject: [PATCH 041/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=A6=AC=EC=8A=A4=ED=8A=B8,=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterType: 첫 글자만 대문자, 나머지 소문자로 변경 - 이미지가 null 이면 ""으로 변경 --- .../admin/chat/character/AdminChatCharacterController.kt | 4 ++-- .../admin/chat/character/dto/ChatCharacterDetailResponse.kt | 2 +- .../co/vividnext/sodalive/chat/character/ChatCharacter.kt | 6 +++--- .../sodalive/chat/character/service/ChatCharacterService.kt | 4 ++-- 4 files changed, 8 insertions(+), 8 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 6cb49ca..dca6acd 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 @@ -124,8 +124,8 @@ class AdminChatCharacterController( originalLink = request.originalLink, characterType = request.characterType?.let { runCatching { CharacterType.valueOf(it) } - .getOrDefault(CharacterType.CHARACTER) - } ?: CharacterType.CHARACTER, + .getOrDefault(CharacterType.Character) + } ?: CharacterType.Character, tags = request.tags, values = request.values, hobbies = request.hobbies, 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 ce7a21b..eb42368 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 @@ -31,7 +31,7 @@ data class ChatCharacterDetailResponse( val fullImagePath = if (chatCharacter.imagePath != null && imageHost.isNotEmpty()) { "$imageHost/${chatCharacter.imagePath}" } else { - chatCharacter.imagePath + chatCharacter.imagePath ?: "" } return ChatCharacterDetailResponse( 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 72f7caf..2b16565 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 @@ -54,7 +54,7 @@ class ChatCharacter( // 캐릭터 유형 @Enumerated(EnumType.STRING) @Column(nullable = false) - var characterType: CharacterType = CharacterType.CHARACTER, + var characterType: CharacterType = CharacterType.Character, var isActive: Boolean = true ) : BaseEntity() { @@ -134,6 +134,6 @@ class ChatCharacter( } enum class CharacterType { - CLONE, - CHARACTER + Clone, + Character } 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 73ac088..81c8a94 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 @@ -225,7 +225,7 @@ class ChatCharacterService( appearance: String? = null, originalTitle: String? = null, originalLink: String? = null, - characterType: CharacterType = CharacterType.CHARACTER, + characterType: CharacterType = CharacterType.Character, tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), @@ -309,7 +309,7 @@ class ChatCharacterService( appearance: String? = null, originalTitle: String? = null, originalLink: String? = null, - characterType: CharacterType = CharacterType.CHARACTER, + characterType: CharacterType = CharacterType.Character, tags: List = emptyList(), values: List = emptyList(), hobbies: List = emptyList(), From cc9e4f974f80693eeec9f5453232c9a0da2c3720 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 17:38:45 +0900 Subject: [PATCH 042/119] =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20Controlle?= =?UTF-8?q?r=20-=20exception=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/AdminChatCharacterController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 dca6acd..6d9b88c 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 @@ -392,7 +392,8 @@ class AdminChatCharacterController( if (!apiResponse.success) { throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.") } - } catch (_: Exception) { + } catch (e: Exception) { + e.printStackTrace() throw SodaException("수정에 실패했습니다. 다시 시도해 주세요.") } } From 7a70a770bb36bde2eb104eba90eaac34759679cf Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 17:54:39 +0900 Subject: [PATCH 043/119] =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20Controlle?= =?UTF-8?q?r=20-=20exception=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/chat/character/AdminChatCharacterController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6d9b88c..ef05865 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 @@ -394,7 +394,7 @@ class AdminChatCharacterController( } } catch (e: Exception) { e.printStackTrace() - throw SodaException("수정에 실패했습니다. 다시 시도해 주세요.") + throw SodaException("${e.message} 수정에 실패했습니다. 다시 시도해 주세요.") } } } From 1db20d118dae053c3e38ff6b65b99df54b52a9f1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 18:08:45 +0900 Subject: [PATCH 044/119] =?UTF-8?q?ExternalApiResponse=20-=20=EA=B0=81=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20JsonProperty=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/admin/chat/character/dto/ChatCharacterDto.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 7cdf9df..baf6881 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 @@ -47,13 +47,13 @@ data class ChatCharacterRegisterRequest( ) data class ExternalApiResponse( - val success: Boolean, - val data: ExternalApiData? = null, - val message: String? = null + @JsonProperty("success") val success: Boolean, + @JsonProperty("data") val data: ExternalApiData? = null, + @JsonProperty("message") val message: String? = null ) data class ExternalApiData( - val id: String + @JsonProperty("id") val id: String ) data class ChatCharacterUpdateRequest( From 8defc56d1eef5a23d4079ea89bd4e2b1ca6d0f75 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 18:22:37 +0900 Subject: [PATCH 045/119] =?UTF-8?q?ExternalApiData=EC=97=90=20@JsonIgnoreP?= =?UTF-8?q?roperties(ignoreUnknown=20=3D=20true)=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EC=97=AC=20=EC=97=86=EB=8A=94=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20=EB=AC=B4=EC=8B=9C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/admin/chat/character/dto/ChatCharacterDto.kt | 2 ++ 1 file changed, 2 insertions(+) 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 baf6881..6b89ac7 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.admin.chat.character.dto +import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty data class ChatCharacterPersonalityRequest( @@ -52,6 +53,7 @@ data class ExternalApiResponse( @JsonProperty("message") val message: String? = null ) +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalApiData( @JsonProperty("id") val id: String ) From 74a612704ed3872120c237619264d73440f7f0d3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 20:40:25 +0900 Subject: [PATCH 046/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그 중복 매핑이 되지 않도록 수정 --- .../kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt | 2 +- .../sodalive/chat/character/service/ChatCharacterService.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 2b16565..4d2e11b 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 @@ -72,7 +72,7 @@ class ChatCharacter( @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) var relationships: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var tagMappings: MutableList = mutableListOf() @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) 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 81c8a94..7f9e20c 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 @@ -120,7 +120,7 @@ class ChatCharacterService( */ @Transactional fun addTagsToCharacter(chatCharacter: ChatCharacter, tags: List) { - tags.forEach { addTagToCharacter(chatCharacter, it) } + tags.distinct().forEach { addTagToCharacter(chatCharacter, it) } } /** From eed755fd11130dca510bf90a70bed3cc8d04ca65 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 21:03:06 +0900 Subject: [PATCH 047/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 태그 중복 매핑이 되지 않도록 수정 --- .../character/service/ChatCharacterService.kt | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 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 7f9e20c..9f1b1cd 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 @@ -123,6 +123,31 @@ class ChatCharacterService( tags.distinct().forEach { addTagToCharacter(chatCharacter, it) } } + /** + * 태그 매핑을 증분 업데이트(추가/삭제만)하여 불필요한 매핑 레코드 재생성을 방지 + */ + @Transactional + fun updateTagsForCharacter(chatCharacter: ChatCharacter, tags: List) { + val desired = tags.distinct() + // 현재 매핑된 태그 문자열 목록 + val current = chatCharacter.tagMappings.map { it.tag.tag } + + // 추가가 필요한 태그 + val toAdd = desired.filterNot { current.contains(it) } + toAdd.forEach { addTagToCharacter(chatCharacter, it) } + + // 제거가 필요한 태그 매핑 + if (chatCharacter.tagMappings.isNotEmpty()) { + val iterator = chatCharacter.tagMappings.iterator() + while (iterator.hasNext()) { + val mapping = iterator.next() + if (!desired.contains(mapping.tag.tag)) { + iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리 + } + } + } + } + /** * 여러 가치관을 한번에 캐릭터에 연결 */ @@ -412,8 +437,7 @@ class ChatCharacterService( // request에서 변경된 데이터만 업데이트 if (request.tags != null) { - chatCharacter.tagMappings.clear() - addTagsToCharacter(chatCharacter, request.tags) + updateTagsForCharacter(chatCharacter, request.tags) } if (request.values != null) { From 147b8b0a42a4cf6cdda727b61fc40c23dec5ddaf Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 21:51:52 +0900 Subject: [PATCH 048/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가치관, 취미, 목표가 중복 매핑이 되지 않도록 수정 --- .../sodalive/chat/character/ChatCharacter.kt | 6 +- .../character/service/ChatCharacterService.kt | 69 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) 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 4d2e11b..de39635 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 @@ -75,13 +75,13 @@ class ChatCharacter( @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var tagMappings: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var valueMappings: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var hobbyMappings: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var goalMappings: MutableList = mutableListOf() // 태그 추가 헬퍼 메소드 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 9f1b1cd..8cc3076 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 @@ -172,6 +172,66 @@ class ChatCharacterService( goals.forEach { addGoalToCharacter(chatCharacter, it) } } + /** + * 가치관 매핑 증분 업데이트 + */ + @Transactional + fun updateValuesForCharacter(chatCharacter: ChatCharacter, values: List) { + val desired = values.distinct() + val current = chatCharacter.valueMappings.map { it.value.value } + val toAdd = desired.filterNot { current.contains(it) } + toAdd.forEach { addValueToCharacter(chatCharacter, it) } + if (chatCharacter.valueMappings.isNotEmpty()) { + val iterator = chatCharacter.valueMappings.iterator() + while (iterator.hasNext()) { + val mapping = iterator.next() + if (!desired.contains(mapping.value.value)) { + iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리 + } + } + } + } + + /** + * 취미 매핑 증분 업데이트 + */ + @Transactional + fun updateHobbiesForCharacter(chatCharacter: ChatCharacter, hobbies: List) { + val desired = hobbies.distinct() + val current = chatCharacter.hobbyMappings.map { it.hobby.hobby } + val toAdd = desired.filterNot { current.contains(it) } + toAdd.forEach { addHobbyToCharacter(chatCharacter, it) } + if (chatCharacter.hobbyMappings.isNotEmpty()) { + val iterator = chatCharacter.hobbyMappings.iterator() + while (iterator.hasNext()) { + val mapping = iterator.next() + if (!desired.contains(mapping.hobby.hobby)) { + iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리 + } + } + } + } + + /** + * 목표 매핑 증분 업데이트 + */ + @Transactional + fun updateGoalsForCharacter(chatCharacter: ChatCharacter, goals: List) { + val desired = goals.distinct() + val current = chatCharacter.goalMappings.map { it.goal.goal } + val toAdd = desired.filterNot { current.contains(it) } + toAdd.forEach { addGoalToCharacter(chatCharacter, it) } + if (chatCharacter.goalMappings.isNotEmpty()) { + val iterator = chatCharacter.goalMappings.iterator() + while (iterator.hasNext()) { + val mapping = iterator.next() + if (!desired.contains(mapping.goal.goal)) { + iterator.remove() // orphanRemoval=true 이므로 매핑 엔티티 삭제 처리 + } + } + } + } + /** * 캐릭터 저장 */ @@ -441,18 +501,15 @@ class ChatCharacterService( } if (request.values != null) { - chatCharacter.valueMappings.clear() - addValuesToCharacter(chatCharacter, request.values) + updateValuesForCharacter(chatCharacter, request.values) } if (request.hobbies != null) { - chatCharacter.hobbyMappings.clear() - addHobbiesToCharacter(chatCharacter, request.hobbies) + updateHobbiesForCharacter(chatCharacter, request.hobbies) } if (request.goals != null) { - chatCharacter.goalMappings.clear() - addGoalsToCharacter(chatCharacter, request.goals) + updateGoalsForCharacter(chatCharacter, request.goals) } // 추가 정보 설정 - 변경된 데이터만 업데이트 From d99fcba468718eb53a681551f3449fad5881585f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 22:47:56 +0900 Subject: [PATCH 049/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - request를 JSON String으로 받도록 수정 --- .../admin/chat/AdminChatBannerController.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index d4831fb..8835044 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.chat 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.service.AdminChatCharacterService import kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerListPageResponse @@ -105,14 +106,20 @@ class AdminChatBannerController( * 배너 등록 API * * @param image 배너 이미지 - * @param request 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함) + * @param requestString 배너 등록 요청 정보 (캐릭터 ID와 선택적으로 정렬 순서 포함) * @return 등록된 배너 정보 */ @PostMapping("/register") fun registerBanner( @RequestPart("image") image: MultipartFile, - @RequestPart("request") request: ChatCharacterBannerRegisterRequest + @RequestPart("request") requestString: String ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue( + requestString, + ChatCharacterBannerRegisterRequest::class.java + ) + // 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함) val banner = bannerService.registerBanner( characterId = request.characterId, @@ -160,14 +167,19 @@ class AdminChatBannerController( * 배너 수정 API * * @param image 배너 이미지 - * @param request 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함) + * @param requestString 배너 수정 요청 정보 (배너 ID와 선택적으로 캐릭터 ID 포함) * @return 수정된 배너 정보 */ @PutMapping("/update") fun updateBanner( @RequestPart("image") image: MultipartFile, - @RequestPart("request") request: ChatCharacterBannerUpdateRequest + @RequestPart("request") requestString: String ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue( + requestString, + ChatCharacterBannerUpdateRequest::class.java + ) // 배너 정보 조회 bannerService.getBannerById(request.bannerId) From 168b0b13fbcbc7df97141c8dbfa037f9d074fb2f Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 23:02:18 +0900 Subject: [PATCH 050/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - request dto에 JsonProperty 추가 --- .../admin/chat/dto/ChatCharacterBannerRequest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt index c4f6b33..930c8e1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerRequest.kt @@ -1,11 +1,13 @@ package kr.co.vividnext.sodalive.admin.chat.dto +import com.fasterxml.jackson.annotation.JsonProperty + /** * 캐릭터 배너 등록 요청 DTO */ data class ChatCharacterBannerRegisterRequest( // 캐릭터 ID - val characterId: Long + @JsonProperty("characterId") val characterId: Long ) /** @@ -13,10 +15,10 @@ data class ChatCharacterBannerRegisterRequest( */ data class ChatCharacterBannerUpdateRequest( // 배너 ID - val bannerId: Long, + @JsonProperty("bannerId") val bannerId: Long, // 캐릭터 ID (변경할 캐릭터) - val characterId: Long? = null + @JsonProperty("characterId") val characterId: Long? = null ) /** @@ -24,5 +26,5 @@ data class ChatCharacterBannerUpdateRequest( */ data class UpdateBannerOrdersRequest( // 배너 ID 목록 (순서대로 정렬됨) - val ids: List + @JsonProperty("ids") val ids: List ) From 1b7ae8a2c504c02227829a0b9644a423c002d890 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 23:15:34 +0900 Subject: [PATCH 051/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배너 이미지 저장 경로 수정 --- .../vividnext/sodalive/admin/chat/AdminChatBannerController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 8835044..33de64d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -155,7 +155,7 @@ class AdminChatBannerController( return s3Uploader.upload( inputStream = image.inputStream, bucket = s3Bucket, - filePath = "/characters/banners/$bannerId/$fileName", + filePath = "/character-banner/$bannerId/$fileName", metadata = metadata ) } catch (e: Exception) { From 5d4280551433e8e4384164e926c27070f4d723eb Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 23:26:13 +0900 Subject: [PATCH 052/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=93=B1=EB=A1=9D/?= =?UTF-8?q?=EC=88=98=EC=A0=95=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배너 이미지 저장 경로 수정 --- .../vividnext/sodalive/admin/chat/AdminChatBannerController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt index 33de64d..968bc61 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/AdminChatBannerController.kt @@ -155,7 +155,7 @@ class AdminChatBannerController( return s3Uploader.upload( inputStream = image.inputStream, bucket = s3Bucket, - filePath = "/character-banner/$bannerId/$fileName", + filePath = "characters/banners/$bannerId/$fileName", metadata = metadata ) } catch (e: Exception) { From 80a0543e100d682299e8b863d5284e7483054481 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 12 Aug 2025 23:38:18 +0900 Subject: [PATCH 053/119] =?UTF-8?q?feat(admin-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=B0=B0=EB=84=88=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배너 이미지 URL - hostimagePath => host/imagePath로 수정 --- .../sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt index 4afd07b..25ebcc7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/dto/ChatCharacterBannerResponse.kt @@ -15,7 +15,7 @@ data class ChatCharacterBannerResponse( fun from(banner: ChatCharacterBanner, imageHost: String): ChatCharacterBannerResponse { return ChatCharacterBannerResponse( id = banner.id!!, - imagePath = "$imageHost${banner.imagePath}", + imagePath = "$imageHost/${banner.imagePath}", characterId = banner.chatCharacter.id!!, characterName = banner.chatCharacter.name ) From 005bb0ea2e4f562d4efd73e5aef1abd8014ab616 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 Aug 2025 00:08:10 +0900 Subject: [PATCH 054/119] =?UTF-8?q?feat(chat):=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=B5=9C=EA=B7=BC=EC=97=90=20=EB=8C=80=ED=99=94?= =?UTF-8?q?=ED=95=9C=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=AA=A9=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatCharacterRepository.kt의 JPQL 정렬 절을 `ORDER BY MAX(COALESCE(m.createdAt, r.createdAt)) DESC`로 변경 --- .../chat/character/repository/ChatCharacterRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bcc19fb..9c5d918 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 @@ -59,7 +59,7 @@ interface ChatCharacterRepository : JpaRepository { AND pc.isActive = true AND r.isActive = true GROUP BY c.id - ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC + ORDER BY MAX(COALESCE(m.createdAt, r.createdAt)) DESC """ ) fun findRecentCharactersByMember( From 6f9fc659f37d4f133b66f27ca80381776388b762 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 Aug 2025 14:39:20 +0900 Subject: [PATCH 055/119] =?UTF-8?q?feat(chat):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=B1=97=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?API=20URL=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/session/... -> /api/sessions/... --- .../vividnext/sodalive/chat/room/service/ChatRoomService.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 42d24e0..78117bb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -150,7 +150,7 @@ class ChatRoomService( val httpEntity = HttpEntity(requestBody, headers) val response = restTemplate.exchange( - "$apiUrl/api/session", + "$apiUrl/api/sessions", HttpMethod.POST, httpEntity, String::class.java @@ -238,7 +238,7 @@ class ChatRoomService( val httpEntity = HttpEntity(null, headers) val response = restTemplate.exchange( - "$apiUrl/api/session/$sessionId", + "$apiUrl/api/sessions/$sessionId", HttpMethod.GET, httpEntity, String::class.java @@ -307,7 +307,7 @@ class ChatRoomService( val httpEntity = HttpEntity(null, headers) val response = restTemplate.exchange( - "$apiUrl/api/session/$sessionId/end", + "$apiUrl/api/sessions/$sessionId/end", HttpMethod.PUT, httpEntity, String::class.java From 3ac4ebded319d13a34853c591fb2303f0e85faaa Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 Aug 2025 16:49:45 +0900 Subject: [PATCH 056/119] =?UTF-8?q?feat(chat):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=B1=97=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 응답값에 Response 모델에 JsonIgnoreProperties 추가하여 필요한 데이터만 파싱할 수 있도록 수정 --- .../sodalive/chat/room/dto/ChatRoomDto.kt | 60 +++++++++++-------- .../chat/room/service/ChatRoomService.kt | 10 ++-- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 79bd7cb..3849ff5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -1,5 +1,8 @@ package kr.co.vividnext.sodalive.chat.room.dto +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + /** * 채팅방 생성 요청 DTO */ @@ -46,10 +49,10 @@ data class ChatRoomListQueryDto( /** * 외부 API 채팅 세션 생성 응답 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSessionCreateResponse( - val success: Boolean, - val message: String?, - val data: ExternalChatSessionCreateData? + @JsonProperty("success") val success: Boolean, + @JsonProperty("data") val data: ExternalChatSessionCreateData? ) /** @@ -57,21 +60,22 @@ data class ExternalChatSessionCreateResponse( * 공통: sessionId, status * 생성 전용: userId, characterId, character, createdAt */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSessionCreateData( - val sessionId: String, - val userId: String, - val characterId: String, - val character: ExternalCharacterData, - val status: String, - val createdAt: String + @JsonProperty("sessionId") val sessionId: String, + @JsonProperty("userId") val userId: String, + @JsonProperty("character") val character: ExternalCharacterData, + @JsonProperty("status") val status: String, + @JsonProperty("createdAt") val createdAt: String, + @JsonProperty("message") val message: String? ) /** * 외부 API 채팅 세션 조회 응답 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSessionGetResponse( val success: Boolean, - val message: String?, val data: ExternalChatSessionGetData? ) @@ -79,19 +83,23 @@ data class ExternalChatSessionGetResponse( * 외부 API 채팅 세션 조회 데이터 DTO * 세션 조회에서 사용하는 공통 필드만 포함 */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSessionGetData( - val sessionId: String, - val status: String + @JsonProperty("sessionId") val sessionId: String, + @JsonProperty("userId") val userId: String, + @JsonProperty("characterId") val characterId: String, + @JsonProperty("status") val status: String ) /** * 외부 API 캐릭터 데이터 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalCharacterData( - val id: String, - val name: String, - val age: String, - val gender: String + @JsonProperty("id") val id: String, + @JsonProperty("name") val name: String, + @JsonProperty("age") val age: String, + @JsonProperty("gender") val gender: String ) /** @@ -111,26 +119,28 @@ data class SendChatMessageResponse( /** * 외부 API 채팅 전송 응답 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSendResponse( - val success: Boolean, - val message: String?, - val data: ExternalChatSendData? + @JsonProperty("success") val success: Boolean, + @JsonProperty("data") val data: ExternalChatSendData? ) /** * 외부 API 채팅 전송 데이터 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalChatSendData( - val sessionId: String, - val characterResponse: ExternalCharacterMessage + @JsonProperty("sessionId") val sessionId: String, + @JsonProperty("characterResponse") val characterResponse: ExternalCharacterMessage ) /** * 외부 API 캐릭터 메시지 DTO */ +@JsonIgnoreProperties(ignoreUnknown = true) data class ExternalCharacterMessage( - val id: String, - val content: String, - val timestamp: String, - val messageType: String + @JsonProperty("id") val id: String, + @JsonProperty("content") val content: String, + @JsonProperty("timestamp") val timestamp: String, + @JsonProperty("messageType") val messageType: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 78117bb..f04d0c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -165,21 +165,21 @@ class ChatRoomService( // success가 false이면 throw if (!apiResponse.success) { - throw SodaException(apiResponse.message ?: "채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") } // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") - if (data.userId != userId && data.characterId != characterUUID && data.status != "active") { + if (data.userId != userId && data.character.id != characterUUID && data.status != "active") { throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") } // 세션 ID 반환 return data.sessionId } catch (e: Exception) { - e.printStackTrace() - throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") + log.error(e.message) + throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") } } @@ -484,7 +484,7 @@ class ChatRoomService( ) if (!apiResponse.success) { - throw SodaException(apiResponse.message ?: "메시지 전송을 실패했습니다.") + throw SodaException("메시지 전송을 실패했습니다.") } val data = apiResponse.data ?: throw SodaException("메시지 전송을 실패했습니다.") val characterContent = data.characterResponse.content From e6d63592ec67541a867e7a51fa025b8294e5afc3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 13 Aug 2025 19:49:46 +0900 Subject: [PATCH 057/119] =?UTF-8?q?fix(chat-character):=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=97=94=ED=8B=B0=ED=8B=B0/CR?= =?UTF-8?q?UD/=EC=9D=91=EB=8B=B5=20DTO=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatCharacterRelationship 엔티티를 personName, relationshipName, description(TEXT), importance, relationshipType, currentStatus로 변경 - ChatCharacter.addRelationship 및 Service 메서드 시그니처를 새 스키마에 맞게 수정 - 등록/수정 플로우에서 relationships 매핑 로직 업데이트 - Admin 상세 응답 DTO(RelationshipResponse) 및 매핑 업데이트 - 전체 빌드 성공 --- .../character/AdminChatCharacterController.kt | 2 +- .../dto/ChatCharacterDetailResponse.kt | 19 ++++++-- .../chat/character/dto/ChatCharacterDto.kt | 8 +++- .../sodalive/chat/character/ChatCharacter.kt | 19 +++++++- .../character/ChatCharacterRelationship.kt | 16 ++++++- .../character/service/ChatCharacterService.kt | 44 ++++++++++++++++--- 6 files changed, 91 insertions(+), 17 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 ef05865..ab05224 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 @@ -133,7 +133,7 @@ class AdminChatCharacterController( memories = request.memories.map { Triple(it.title, it.content, it.emotion) }, personalities = request.personalities.map { Pair(it.trait, it.description) }, backgrounds = request.backgrounds.map { Pair(it.topic, it.description) }, - relationships = request.relationships.map { it.name to it.relationShip } + relationships = request.relationships ) // 3. 이미지 저장 및 ChatCharacter에 이미지 path 설정 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 eb42368..fa006db 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 @@ -53,7 +53,16 @@ data class ChatCharacterDetailResponse( hobbies = chatCharacter.hobbyMappings.map { it.hobby.hobby }, values = chatCharacter.valueMappings.map { it.value.value }, goals = chatCharacter.goalMappings.map { it.goal.goal }, - relationships = chatCharacter.relationships.map { RelationshipResponse(it.name, it.relationShip) }, + relationships = chatCharacter.relationships.map { + RelationshipResponse( + personName = it.personName, + relationshipName = it.relationshipName, + description = it.description, + importance = it.importance, + relationshipType = it.relationshipType, + currentStatus = it.currentStatus + ) + }, personalities = chatCharacter.personalities.map { PersonalityResponse(it.trait, it.description) }, @@ -85,6 +94,10 @@ data class MemoryResponse( ) data class RelationshipResponse( - val name: String, - val relationShip: String + val personName: String, + val relationshipName: String, + val description: String, + val importance: Int, + val relationshipType: String, + val currentStatus: String ) 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 6b89ac7..0710330 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 @@ -20,8 +20,12 @@ data class ChatCharacterMemoryRequest( ) data class ChatCharacterRelationshipRequest( - @JsonProperty("name") val name: String, - @JsonProperty("relationShip") val relationShip: String + @JsonProperty("personName") val personName: String, + @JsonProperty("relationshipName") val relationshipName: String, + @JsonProperty("description") val description: String, + @JsonProperty("importance") val importance: Int, + @JsonProperty("relationshipType") val relationshipType: String, + @JsonProperty("currentStatus") val currentStatus: String ) data class ChatCharacterRegisterRequest( 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 de39635..75d8680 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 @@ -127,8 +127,23 @@ class ChatCharacter( } // 관계 추가 헬퍼 메소드 - fun addRelationship(name: String, relationShip: String) { - val relationship = ChatCharacterRelationship(name, relationShip, this) + fun addRelationship( + personName: String, + relationshipName: String, + description: String, + importance: Int, + relationshipType: String, + currentStatus: String + ) { + val relationship = ChatCharacterRelationship( + personName, + relationshipName, + description, + importance, + relationshipType, + currentStatus, + this + ) relationships.add(relationship) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt index d0b6e2c..5ef16c2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterRelationship.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column import javax.persistence.Entity import javax.persistence.FetchType import javax.persistence.JoinColumn @@ -12,8 +13,19 @@ import javax.persistence.ManyToOne @Entity class ChatCharacterRelationship( - var name: String, - val relationShip: String, + // 상대 인물 이름 + var personName: String, + // 관계명 (예: 친구, 동료 등) + var relationshipName: String, + // 관계 설명 + @Column(columnDefinition = "TEXT") + var description: String, + // 중요도 + var importance: Int, + // 관계 타입 (분류용) + var relationshipType: String, + // 현재 상태 + var currentStatus: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_character_id") 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 8cc3076..8993429 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 @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.chat.character.service +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest 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 @@ -372,8 +373,23 @@ class ChatCharacterService( * 캐릭터에 관계 추가 */ @Transactional - fun addRelationshipToChatCharacter(chatCharacter: ChatCharacter, name: String, relationShip: String) { - chatCharacter.addRelationship(name, relationShip) + fun addRelationshipToChatCharacter( + chatCharacter: ChatCharacter, + personName: String, + relationshipName: String, + description: String, + importance: Int, + relationshipType: String, + currentStatus: String + ) { + chatCharacter.addRelationship( + personName, + relationshipName, + description, + importance, + relationshipType, + currentStatus + ) saveChatCharacter(chatCharacter) } @@ -402,7 +418,7 @@ class ChatCharacterService( memories: List> = emptyList(), personalities: List> = emptyList(), backgrounds: List> = emptyList(), - relationships: List> = emptyList() + relationships: List = emptyList() ): ChatCharacter { val chatCharacter = createChatCharacter( characterUUID = characterUUID, @@ -437,8 +453,15 @@ class ChatCharacterService( chatCharacter.addBackground(topic, description) } - relationships.forEach { (name, relationShip) -> - chatCharacter.addRelationship(name, relationShip) + relationships.forEach { rr -> + chatCharacter.addRelationship( + rr.personName, + rr.relationshipName, + rr.description, + rr.importance, + rr.relationshipType, + rr.currentStatus + ) } return saveChatCharacter(chatCharacter) @@ -536,8 +559,15 @@ class ChatCharacterService( if (request.relationships != null) { chatCharacter.relationships.clear() - request.relationships.forEach { relationship -> - chatCharacter.addRelationship(relationship.name, relationship.relationShip) + request.relationships.forEach { rr -> + chatCharacter.addRelationship( + rr.personName, + rr.relationshipName, + rr.description, + rr.importance, + rr.relationshipType, + rr.currentStatus + ) } } From 6cf7dabaef7b5c6ab7fe2897d14b7d3d96d4c47e Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 00:53:35 +0900 Subject: [PATCH 058/119] =?UTF-8?q?feat(character):=20=ED=99=88=EC=9D=98?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomService.listMyChatRooms 사용, 최근 순 최대 10개 노출 - 방 title/imageUrl을 그대로 사용해 UI/데이터 일관성 유지 - 비로그인 사용자는 빈 배열 반환 refactor(dto): RecentCharacter.characterId → roomId로 변경 --- .../controller/ChatCharacterController.kt | 24 ++++++++++------- .../character/dto/CharacterHomeResponse.kt | 2 +- .../repository/ChatCharacterRepository.kt | 26 ------------------- .../character/service/ChatCharacterService.kt | 11 -------- .../chat/room/service/ChatRoomService.kt | 7 +++-- 5 files changed, 19 insertions(+), 51 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index b3653fe..67df177 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -11,6 +11,7 @@ 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.service.ChatCharacterBannerService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member @@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController class ChatCharacterController( private val service: ChatCharacterService, private val bannerService: ChatCharacterBannerService, + private val chatRoomService: ChatRoomService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -45,15 +47,19 @@ class ChatCharacterController( ) } - // 최근 대화한 캐릭터 조회 (회원별 최근 순으로 최대 10개) - val recentCharacters = service.getRecentCharacters(member, 10) - .map { - RecentCharacter( - characterId = it.id!!, - name = it.name, - imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" - ) - } + // 최근 대화한 캐릭터(채팅방) 조회 (회원별 최근 순으로 최대 10개) + val recentCharacters = if (member == null || member.auth == null) { + emptyList() + } else { + chatRoomService.listMyChatRooms(member, 0, 10) + .map { room -> + RecentCharacter( + roomId = room.chatRoomId, + name = room.title, + imageUrl = room.imageUrl + ) + } + } // 인기 캐릭터 조회 (현재는 빈 리스트) val popularCharacters = service.getPopularCharacters() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt index b471315..745d2d2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -22,7 +22,7 @@ data class Character( ) data class RecentCharacter( - val characterId: Long, + val roomId: Long, val name: String, val imageUrl: String ) 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 9c5d918..ede9fa5 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 @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.chat.character.repository import kr.co.vividnext.sodalive.chat.character.ChatCharacter -import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -42,31 +41,6 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): Page - /** - * 멤버가 최근에 대화한 캐릭터 목록을 반환 (최신 메시지 시간 기준 내림차순) - */ - @Query( - value = """ - SELECT c FROM ChatRoom r - JOIN r.participants pu - JOIN r.participants pc - JOIN pc.character c - LEFT JOIN r.messages m - WHERE pu.member = :member - AND pu.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.USER - AND pu.isActive = true - AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER - AND pc.isActive = true - AND r.isActive = true - GROUP BY c.id - ORDER BY MAX(COALESCE(m.createdAt, r.createdAt)) DESC - """ - ) - fun findRecentCharactersByMember( - @Param("member") member: Member, - pageable: Pageable - ): List - /** * 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외) */ 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 8993429..cdec44d 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 @@ -13,7 +13,6 @@ import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepo import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository -import kr.co.vividnext.sodalive.member.Member import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -26,16 +25,6 @@ class ChatCharacterService( private val hobbyRepository: ChatCharacterHobbyRepository, private val goalRepository: ChatCharacterGoalRepository ) { - - /** - * 최근에 대화한 캐릭터 목록 조회 - */ - @Transactional(readOnly = true) - fun getRecentCharacters(member: Member?, limit: Int = 10): List { - if (member == null) return emptyList() - return chatCharacterRepository.findRecentCharactersByMember(member, PageRequest.of(0, limit)) - } - /** * 일주일간 대화가 가장 많은 인기 캐릭터 목록 조회 * 현재는 채팅방 구현 전이므로 빈 리스트 반환 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index f04d0c4..e539ae2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -184,9 +184,8 @@ class ChatRoomService( } @Transactional(readOnly = true) - fun listMyChatRooms(member: Member, page: Int): List { - // 기본 페이지당 20개 고정 - val pageable = PageRequest.of(if (page < 0) 0 else page, 20) + fun listMyChatRooms(member: Member, page: Int, size: Int = 20): List { + val pageable = PageRequest.of(if (page < 0) 0 else page, size) val rooms: List = chatRoomRepository.findMemberRoomsOrderByLastMessageDesc( member, pageable @@ -200,7 +199,7 @@ class ChatRoomService( val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room) val preview = latest?.message?.let { msg -> - if (msg.length <= 25) msg else msg.substring(0, 25) + "..." + if (msg.length <= 25) msg else msg.take(25) + "..." } ChatRoomListItemDto( From f2ca013b9612e17095930605699b4f2f31f202b8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 13:39:59 +0900 Subject: [PATCH 059/119] =?UTF-8?q?feat(chat-room):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0(=ED=83=80=EC=9E=85/=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0/=EC=83=81=EB=8C=80=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80/=EC=8B=9C=EA=B0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatRoomListQueryDto: characterType, lastActivityAt 필드 추가 - ChatRoomListItemDto: opponentType, lastMessagePreview, lastMessageTimeLabel 제공 - 레포지토리 정렬 기준을 최근 메시지 또는 생성일로 일원화(COALESCE) --- .../sodalive/chat/room/dto/ChatRoomDto.kt | 11 ++++++-- .../room/repository/ChatRoomRepository.kt | 4 ++- .../chat/room/service/ChatRoomService.kt | 27 ++++++++++++++++--- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 3849ff5..aa2f930 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.chat.room.dto import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.chat.character.CharacterType +import java.time.LocalDateTime /** * 채팅방 생성 요청 DTO @@ -24,7 +26,9 @@ data class ChatRoomListItemDto( val chatRoomId: Long, val title: String, val imageUrl: String, - val lastMessagePreview: String? + val opponentType: String, + val lastMessagePreview: String?, + val lastMessageTimeLabel: String ) /** @@ -40,10 +44,13 @@ data class ChatMessageItemDto( /** * 채팅방 목록 쿼리 DTO (레포지토리 투영용) */ + data class ChatRoomListQueryDto( val chatRoomId: Long, val title: String, - val imagePath: String? + val imagePath: String?, + val characterType: CharacterType, + val lastActivityAt: LocalDateTime? ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 0b719a0..a01fe62 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -40,7 +40,9 @@ interface ChatRoomRepository : JpaRepository { SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto( r.id, r.title, - pc.character.imagePath + pc.character.imagePath, + pc.character.characterType, + COALESCE(MAX(m.createdAt), r.createdAt) ) FROM ChatRoom r JOIN r.participants p diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index e539ae2..9ecedeb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -199,18 +199,39 @@ class ChatRoomService( val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room) val preview = latest?.message?.let { msg -> - if (msg.length <= 25) msg else msg.take(25) + "..." + if (msg.length <= 30) msg else msg.take(30) + "..." } + val imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}" + val opponentType = q.characterType.name // Clone or Character + val time = latest?.createdAt ?: q.lastActivityAt + val timeLabel = formatRelativeTime(time) + ChatRoomListItemDto( chatRoomId = q.chatRoomId, title = q.title, - imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}", - lastMessagePreview = preview + imageUrl = imageUrl, + opponentType = opponentType, + lastMessagePreview = preview, + lastMessageTimeLabel = timeLabel ) } } + private fun formatRelativeTime(time: java.time.LocalDateTime?): String { + if (time == null) return "" + val now = java.time.LocalDateTime.now() + val duration = java.time.Duration.between(time, now) + val seconds = duration.seconds + if (seconds <= 60) return "방금" + val minutes = duration.toMinutes() + if (minutes < 60) return "${'$'}minutes분 전" + val hours = duration.toHours() + if (hours < 24) return "${'$'}hours시간 전" + // 그 외: 날짜 (yyyy-MM-dd) + return time.toLocalDate().toString() + } + @Transactional(readOnly = true) fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { From 28bd700b03fa9726862404744bc6657d2ad96578 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 14:09:31 +0900 Subject: [PATCH 060/119] =?UTF-8?q?fix(chat-room):=20ONLY=5FFULL=5FGROUP?= =?UTF-8?q?=5FBY=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=ED=99=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/room/repository/ChatRoomRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index a01fe62..0bdb29b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -53,7 +53,7 @@ interface ChatRoomRepository : JpaRepository { AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER AND pc.isActive = true AND r.isActive = true - GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath + GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath, pc.character.characterType ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC """ ) From 4966aaeda97c6e1de5df91cb8c13855f817a684a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 14:35:07 +0900 Subject: [PATCH 061/119] =?UTF-8?q?fix(chat-room):=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=9E=88=EB=8A=94=20=EB=B0=A9=EB=A7=8C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/room/repository/ChatRoomRepository.kt | 7 ++++--- .../sodalive/chat/room/service/ChatRoomService.kt | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index 0bdb29b..f161cd8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -42,19 +42,20 @@ interface ChatRoomRepository : JpaRepository { r.title, pc.character.imagePath, pc.character.characterType, - COALESCE(MAX(m.createdAt), r.createdAt) + MAX(m.createdAt) ) FROM ChatRoom r JOIN r.participants p JOIN r.participants pc - LEFT JOIN r.messages m + JOIN r.messages m WHERE p.member = :member AND p.isActive = true AND pc.participantType = kr.co.vividnext.sodalive.chat.room.ParticipantType.CHARACTER AND pc.isActive = true AND r.isActive = true + AND m.isActive = true GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath, pc.character.characterType - ORDER BY COALESCE(MAX(m.createdAt), r.createdAt) DESC + ORDER BY MAX(m.createdAt) DESC """ ) fun findMemberRoomsOrderByLastMessageDesc( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 9ecedeb..aa54e26 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -30,6 +30,8 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.client.RestTemplate +import java.time.Duration +import java.time.LocalDateTime import java.util.UUID @Service @@ -218,16 +220,16 @@ class ChatRoomService( } } - private fun formatRelativeTime(time: java.time.LocalDateTime?): String { + private fun formatRelativeTime(time: LocalDateTime?): String { if (time == null) return "" - val now = java.time.LocalDateTime.now() - val duration = java.time.Duration.between(time, now) + val now = LocalDateTime.now() + val duration = Duration.between(time, now) val seconds = duration.seconds if (seconds <= 60) return "방금" val minutes = duration.toMinutes() - if (minutes < 60) return "${'$'}minutes분 전" + if (minutes < 60) return "${minutes}분 전" val hours = duration.toHours() - if (hours < 24) return "${'$'}hours시간 전" + if (hours < 24) return "${hours}시간 전" // 그 외: 날짜 (yyyy-MM-dd) return time.toLocalDate().toString() } From 2d65bdb8ee6d8c6af8baf52c2749d60de52107ad Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 21:43:42 +0900 Subject: [PATCH 062/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EC=97=90=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?createdAt=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor(< messageId) 기준의 커서 페이징 도입, 경계 exclusive 처리 limit 파라미터로 페이지 사이즈 가변화 (기본 20) 응답 스키마를 ChatMessagesPageResponse(messages, hasMore, nextCursor)로 변경 메시지 정렬을 createdAt 오름차순(표시 시간 순)으로 반환 ChatMessageItemDto에 createdAt(epoch millis) 필드 추가 레포지토리에 Pageable 기반 조회 및 이전 데이터 존재 여부 검사 메서드 추가 컨트롤러/서비스 시그니처 및 내부 로직 업데이트 --- .../room/controller/ChatRoomController.kt | 38 ++++++++-------- .../sodalive/chat/room/dto/ChatRoomDto.kt | 12 ++++- .../room/repository/ChatMessageRepository.kt | 14 ++++++ .../chat/room/service/ChatRoomService.kt | 45 ++++++++++++++----- 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 1fbde14..948d6e2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -65,24 +65,6 @@ class ChatRoomController( } } - /** - * 채팅방 메시지 조회 API - * - 참여 여부 검증(미참여시 "잘못된 접근입니다") - * - messageId가 있으면 해당 ID 이전 20개, 없으면 최신 20개 - */ - @GetMapping("/{chatRoomId}/messages") - fun getChatMessages( - @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable chatRoomId: Long, - @RequestParam(required = false) messageId: Long? - ) = run { - if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - - val response = chatRoomService.getChatMessages(member, chatRoomId, messageId) - ApiResponse.ok(response) - } - /** * 세션 상태 조회 API * - 채팅방 참여 여부 검증 @@ -119,6 +101,26 @@ class ChatRoomController( ApiResponse.ok(true) } + /** + * 채팅방 메시지 조회 API + * - 참여 여부 검증(미참여시 "잘못된 접근입니다") + * - cursor(메시지ID)보다 더 과거의 메시지에서 limit만큼 조회(경계 exclusive) + * - cursor 미지정 시 최신부터 limit만큼 기준으로 페이징 + */ + @GetMapping("/{chatRoomId}/messages") + fun getChatMessages( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestParam(defaultValue = "20") limit: Int, + @RequestParam(required = false) cursor: Long? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.getChatMessages(member, chatRoomId, cursor, limit) + ApiResponse.ok(response) + } + /** * 채팅방 메시지 전송 API * - 참여 여부 검증(미참여시 "잘못된 접근입니다") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index aa2f930..48dd667 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -38,7 +38,17 @@ data class ChatMessageItemDto( val messageId: Long, val message: String, val profileImageUrl: String, - val mine: Boolean + val mine: Boolean, + val createdAt: Long +) + +/** + * 채팅방 메시지 페이지 응답 DTO + */ +data class ChatMessagesPageResponse( + val messages: List, + val hasMore: Boolean, + val nextCursor: Long? ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt index de0c5a4..b6799e9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatMessageRepository.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.repository import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.chat.room.ChatRoom +import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @@ -9,10 +10,23 @@ import org.springframework.stereotype.Repository interface ChatMessageRepository : JpaRepository { fun findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(chatRoom: ChatRoom): ChatMessage? + // 기존 20개 고정 메서드는 유지 (기존 호출 호환) fun findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom): List fun findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( chatRoom: ChatRoom, id: Long ): List + + // 새로운 커서 기반 페이징용 메서드 (limit 가변) + fun findByChatRoomAndIsActiveTrueOrderByIdDesc(chatRoom: ChatRoom, pageable: Pageable): List + + fun findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc( + chatRoom: ChatRoom, + id: Long, + pageable: Pageable + ): List + + // 더 이전 데이터 존재 여부 확인 + fun existsByChatRoomAndIsActiveTrueAndIdLessThan(chatRoom: ChatRoom, id: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index aa54e26..7ff7849 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.room.ChatParticipant import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto +import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagesPageResponse import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse @@ -358,35 +359,54 @@ class ChatRoomService( } @Transactional(readOnly = true) - fun getChatMessages(member: Member, chatRoomId: Long, beforeMessageId: Long?): List { + fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") } - val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - if (participant == null) { - throw SodaException("잘못된 접근입니다") - } + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") - val messages = if (beforeMessageId != null) { - messageRepository.findTop20ByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, beforeMessageId) + val pageable = PageRequest.of(0, limit) + val fetched = if (cursor != null) { + messageRepository.findByChatRoomAndIdLessThanAndIsActiveTrueOrderByIdDesc(room, cursor, pageable) } else { - messageRepository.findTop20ByChatRoomAndIsActiveTrueOrderByIdDesc(room) + messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) } - return messages.map { msg -> + // 가장 오래된 메시지 ID (nextCursor) 및 hasMore 계산 + val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id + val hasMore: Boolean = if (nextCursor != null) { + messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor) + } else { + false + } + + // createdAt 오름차순으로 정렬하여 반환 + val messagesAsc = fetched.sortedBy { it.createdAt } + + val items = messagesAsc.map { msg -> val sender = msg.participant val profilePath = when (sender.participantType) { ParticipantType.USER -> sender.member?.profileImage ParticipantType.CHARACTER -> sender.character?.imagePath } val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, profileImageUrl = imageUrl, - mine = sender.member?.id == member.id + mine = sender.member?.id == member.id, + createdAt = createdAtMillis ) } + + return ChatMessagesPageResponse( + messages = items, + hasMore = hasMore, + nextCursor = nextCursor + ) } @Transactional @@ -441,7 +461,10 @@ class ChatRoomService( messageId = savedCharacterMsg.id!!, message = savedCharacterMsg.message, profileImageUrl = imageUrl, - mine = false + mine = false, + createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant() + ?.toEpochMilli() + ?: 0L ) return SendChatMessageResponse(characterMessages = listOf(dto)) From df77e310431299325c81b69467580c43d018a8ed Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 21:56:27 +0900 Subject: [PATCH 063/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=9E=85=EC=9E=A5=20API=EC=99=80=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/chat/room/{chatRoomId}/enter 엔드포인트 추가 - 참여 검증 후 roomId, character(아이디/이름/프로필/타입) 제공 - 최신 20개 메시지 조회(내림차순 조회 후 createdAt 오름차순으로 정렬) - hasMoreMessages 플래그 계산(가장 오래된 메시지 이전 존재 여부 판단) --- .../room/controller/ChatRoomController.kt | 17 +++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 17 +++++ .../chat/room/service/ChatRoomService.kt | 66 +++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 948d6e2..0a6f2a0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -82,6 +82,23 @@ class ChatRoomController( ApiResponse.ok(isActive) } + /** + * 채팅방 입장 API + * - 참여 여부 검증 + * - 최신 20개 메시지를 createdAt 오름차순으로 반환 + */ + @GetMapping("/{chatRoomId}/enter") + fun enterChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.enterChatRoom(member, chatRoomId) + ApiResponse.ok(response) + } + /** * 채팅방 나가기 API * - URL에 chatRoomId 포함 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 48dd667..e5b26e3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -161,3 +161,20 @@ data class ExternalCharacterMessage( @JsonProperty("timestamp") val timestamp: String, @JsonProperty("messageType") val messageType: String ) + +/** + * 채팅방 입장 응답 DTO + */ +data class ChatRoomEnterCharacterDto( + val characterId: Long, + val name: String, + val profileImageUrl: String, + val characterType: String +) + +data class ChatRoomEnterResponse( + val roomId: Long, + val character: ChatRoomEnterCharacterDto, + val messages: List, + val hasMoreMessages: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 7ff7849..bb7049d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -8,6 +8,8 @@ import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagesPageResponse +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomEnterCharacterDto +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomEnterResponse import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse @@ -247,6 +249,70 @@ class ChatRoomService( return fetchSessionActive(room.sessionId) } + @Transactional(readOnly = true) + fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse { + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + // 참여 여부 검증 + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + + // 캐릭터 참여자 조회 + val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( + room, + ParticipantType.CHARACTER + ) ?: throw SodaException("잘못된 접근입니다") + + val character = characterParticipant.character + ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + + val imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}" + val characterDto = ChatRoomEnterCharacterDto( + characterId = character.id!!, + name = character.name, + profileImageUrl = imageUrl, + characterType = character.characterType.name + ) + + // 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 + val pageable = PageRequest.of(0, 20) + val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) + + val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id + val hasMore: Boolean = if (nextCursor != null) { + messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor) + } else { + false + } + + val messagesAsc = fetched.sortedBy { it.createdAt } + val items = messagesAsc.map { msg -> + val sender = msg.participant + val profilePath = when (sender.participantType) { + ParticipantType.USER -> sender.member?.profileImage + ParticipantType.CHARACTER -> sender.character?.imagePath + } + val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + ?: 0L + ChatMessageItemDto( + messageId = msg.id!!, + message = msg.message, + profileImageUrl = senderImageUrl, + mine = sender.member?.id == member.id, + createdAt = createdAtMillis + ) + } + + return ChatRoomEnterResponse( + roomId = room.id!!, + character = characterDto, + messages = items, + hasMoreMessages = hasMore + ) + } + private fun fetchSessionActive(sessionId: String): Boolean { try { val factory = SimpleClientHttpRequestFactory() From 27ed9f61d0655982c60a4fbd90099310d92d8d12 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 14 Aug 2025 22:00:42 +0900 Subject: [PATCH 064/119] =?UTF-8?q?fix(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?API=20=EB=B0=98=ED=99=98=EA=B0=92=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존: SendChatMessageResponse으로 메시지 리스트를 한 번 더 Wrapping해서 보냄 - 수정: 메시지 리스트 반환 --- .../kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt | 7 ------- .../sodalive/chat/room/service/ChatRoomService.kt | 5 ++--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index e5b26e3..e7691b6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -126,13 +126,6 @@ data class SendChatMessageRequest( val message: String ) -/** - * 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트) - */ -data class SendChatMessageResponse( - val characterMessages: List -) - /** * 외부 API 채팅 전송 응답 DTO */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index bb7049d..04c9582 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -16,7 +16,6 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse -import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository @@ -476,7 +475,7 @@ class ChatRoomService( } @Transactional - fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { + fun sendMessage(member: Member, chatRoomId: Long, message: String): List { // 1) 방 존재 확인 val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") @@ -533,7 +532,7 @@ class ChatRoomService( ?: 0L ) - return SendChatMessageResponse(characterMessages = listOf(dto)) + return listOf(dto) } private fun callExternalApiForChatSendWithRetry( From f61c45e89a3446726c63edfb4681ddea62c655a0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 19 Aug 2025 18:47:59 +0900 Subject: [PATCH 065/119] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80/=EB=8B=B5=EA=B8=80=20AP?= =?UTF-8?q?I=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐릭터 댓글 엔티티/레포지토리/서비스/컨트롤러 추가 - 댓글 작성 POST /api/chat/character/{characterId}/comments - 답글 작성 POST /api/chat/character/{characterId}/comments/{commentId}/replies - 댓글 목록 GET /api/chat/character/{characterId}/comments?limit=20 - 답글 목록 GET /api/chat/character/{characterId}/comments/{commentId}/replies?limit=20 - DTO 추가/확장 - CharacterCommentResponse, CharacterReplyResponse, CharacterCommentRepliesResponse, CreateCharacterCommentRequest - 캐릭터 상세 응답(CharacterDetailResponse) 확장 - latestComment(최신 댓글 1건) 추가 - totalComments(전체 활성 댓글 수) 추가 - 성능 최적화: getReplies에서 원본 댓글 replyCount 계산 시 DB 카운트 호출 제거 - toCommentResponse(replyCountOverride) 도입으로 원본 댓글 replyCount=0 고정 - 공통 검증: 로그인/본인인증/빈 내용 체크, 비활성 캐릭터/댓글 차단 WHY - 캐릭터 상세 화면에 댓글 경험 제공 및 전체 댓글 수 노출 요구사항 반영 - 답글 조회 시 불필요한 카운트 쿼리 제거로 DB 호출 최소화 --- .../character/comment/CharacterComment.kt | 39 ++++++ .../comment/CharacterCommentController.kt | 80 ++++++++++++ .../character/comment/CharacterCommentDto.kt | 45 +++++++ .../comment/CharacterCommentRepository.kt | 18 +++ .../comment/CharacterCommentService.kt | 119 ++++++++++++++++++ .../controller/ChatCharacterController.kt | 9 +- .../character/dto/CharacterDetailResponse.kt | 5 +- 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt new file mode 100644 index 0000000..62f1cb9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterComment.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany +import javax.persistence.Table + +@Entity +@Table(name = "character_comment") +data class CharacterComment( + @Column(columnDefinition = "TEXT", nullable = false) + var comment: String, + var isActive: Boolean = true +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id", nullable = true) + var parent: CharacterComment? = null + set(value) { + value?.children?.add(this) + field = value + } + + @OneToMany(mappedBy = "parent") + var children: MutableList = mutableListOf() + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id", nullable = false) + var chatCharacter: ChatCharacter? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt new file mode 100644 index 0000000..fadc70f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +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.PostMapping +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.RestController + +@RestController +@RequestMapping("/api/chat/character") +class CharacterCommentController( + private val service: CharacterCommentService, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + + @PostMapping("/{characterId}/comments") + fun createComment( + @PathVariable characterId: Long, + @RequestBody request: CreateCharacterCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + + val id = service.addComment(characterId, member, request.comment) + ApiResponse.ok(id) + } + + @PostMapping("/{characterId}/comments/{commentId}/replies") + fun createReply( + @PathVariable characterId: Long, + @PathVariable commentId: Long, + @RequestBody request: CreateCharacterCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + + val id = service.addReply(characterId, commentId, member, request.comment) + ApiResponse.ok(id) + } + + @GetMapping("/{characterId}/comments") + fun listComments( + @PathVariable characterId: Long, + @RequestParam(required = false, defaultValue = "20") limit: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val data = service.listComments(imageHost, characterId, limit) + ApiResponse.ok(data) + } + + @GetMapping("/{characterId}/comments/{commentId}/replies") + fun listReplies( + @PathVariable characterId: Long, + @PathVariable commentId: Long, + @RequestParam(required = false, defaultValue = "20") limit: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + // characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨 + val data = service.getReplies(imageHost, commentId, limit) + ApiResponse.ok(data) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt new file mode 100644 index 0000000..437fdf0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -0,0 +1,45 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +// Request DTOs +data class CreateCharacterCommentRequest( + val comment: String +) + +// Response DTOs +// 댓글 Response +// - 댓글 ID +// - 댓글 쓴 Member 프로필 이미지 +// - 댓글 쓴 Member 닉네임 +// - 댓글 쓴 시간 timestamp(long) +// - 답글 수 + +data class CharacterCommentResponse( + val commentId: Long, + val memberProfileImage: String, + val memberNickname: String, + val createdAt: Long, + val replyCount: Int, + val comment: String +) + +// 답글 Response 단건(목록 원소) +// - 답글 ID +// - 답글 쓴 Member 프로필 이미지 +// - 답글 쓴 Member 닉네임 +// - 답글 쓴 시간 timestamp(long) + +data class CharacterReplyResponse( + val replyId: Long, + val memberProfileImage: String, + val memberNickname: String, + val createdAt: Long +) + +// 댓글의 답글 조회 Response 컨테이너 +// - 원본 댓글 Response +// - 답글 목록(위 사양의 필드 포함) + +data class CharacterCommentRepliesResponse( + val original: CharacterCommentResponse, + val replies: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt new file mode 100644 index 0000000..e160fc9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository + +interface CharacterCommentRepository : JpaRepository { + fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( + chatCharacterId: Long, + pageable: Pageable + ): List + + fun countByParent_IdAndIsActiveTrue(parentId: Long): Int + fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List + fun findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(chatCharacterId: Long): CharacterComment? + + // 전체(상위+답글) 활성 댓글 총 개수 + fun countByChatCharacter_IdAndIsActiveTrue(chatCharacterId: Long): Int +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt new file mode 100644 index 0000000..64b6e65 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -0,0 +1,119 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.ZoneId + +@Service +class CharacterCommentService( + private val chatCharacterRepository: ChatCharacterRepository, + private val commentRepository: CharacterCommentRepository +) { + + private fun profileUrl(imageHost: String, profileImage: String?): String { + return if (profileImage.isNullOrBlank()) { + "$imageHost/profile/default-profile.png" + } else { + "$imageHost/$profileImage" + } + } + + private fun toEpochMilli(created: java.time.LocalDateTime?): Long { + return created?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L + } + + private fun toCommentResponse( + imageHost: String, + entity: CharacterComment, + replyCountOverride: Int? = null + ): CharacterCommentResponse { + val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") + return CharacterCommentResponse( + commentId = entity.id!!, + memberProfileImage = profileUrl(imageHost, member.profileImage), + memberNickname = member.nickname, + createdAt = toEpochMilli(entity.createdAt), + replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!), + comment = entity.comment + ) + } + + private fun toReplyResponse(imageHost: String, entity: CharacterComment): CharacterReplyResponse { + val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") + return CharacterReplyResponse( + replyId = entity.id!!, + memberProfileImage = profileUrl(imageHost, member.profileImage), + memberNickname = member.nickname, + createdAt = toEpochMilli(entity.createdAt) + ) + } + + @Transactional + fun addComment(characterId: Long, member: Member, text: String): Long { + val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } + if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") + if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + + val entity = CharacterComment(comment = text) + entity.chatCharacter = character + entity.member = member + commentRepository.save(entity) + return entity.id!! + } + + @Transactional + fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long { + val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") } + if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.") + val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } + if (parent.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") + if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.") + if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.") + + val entity = CharacterComment(comment = text) + entity.chatCharacter = character + entity.member = member + entity.parent = parent + commentRepository.save(entity) + return entity.id!! + } + + @Transactional(readOnly = true) + fun listComments(imageHost: String, characterId: Long, limit: Int = 20): List { + val pageable = PageRequest.of(0, limit) + val comments = commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( + characterId, + pageable + ) + return comments.map { toCommentResponse(imageHost, it) } + } + + @Transactional(readOnly = true) + fun getReplies(imageHost: String, commentId: Long, limit: Int = 20): CharacterCommentRepliesResponse { + val original = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } + if (!original.isActive) throw SodaException("비활성화된 댓글입니다.") + + val pageable = PageRequest.of(0, limit) + val replies = commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable) + + return CharacterCommentRepliesResponse( + original = toCommentResponse(imageHost, original, 0), + replies = replies.map { toReplyResponse(imageHost, it) } + ) + } + + @Transactional(readOnly = true) + fun getLatestComment(imageHost: String, characterId: Long): CharacterCommentResponse? { + val last = commentRepository.findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(characterId) + return last?.let { toCommentResponse(imageHost, it) } + } + + @Transactional(readOnly = true) + fun getTotalCommentCount(characterId: Long): Int { + return commentRepository.countByChatCharacter_IdAndIsActiveTrue(characterId) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 67df177..97912c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.chat.character.controller +import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse @@ -29,6 +30,7 @@ class ChatCharacterController( private val service: ChatCharacterService, private val bannerService: ChatCharacterBannerService, private val chatRoomService: ChatRoomService, + private val characterCommentService: CharacterCommentService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -148,6 +150,9 @@ class ChatCharacterController( ) } + // 최신 댓글 1개 조회 + val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!) + // 응답 생성 ApiResponse.ok( CharacterDetailResponse( @@ -162,7 +167,9 @@ class ChatCharacterController( originalTitle = character.originalTitle, originalLink = character.originalLink, characterType = character.characterType, - others = others + others = others, + latestComment = latestComment, + totalComments = characterCommentService.getTotalCommentCount(character.id!!) ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index cb3f90f..64e3632 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.dto import kr.co.vividnext.sodalive.chat.character.CharacterType +import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse data class CharacterDetailResponse( val characterId: Long, @@ -14,7 +15,9 @@ data class CharacterDetailResponse( val originalTitle: String?, val originalLink: String?, val characterType: CharacterType, - val others: List + val others: List, + val latestComment: CharacterCommentResponse?, + val totalComments: Int ) data class OtherCharacter( From 6c7f41186985fd6bcac0414047b950e03bc77182 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 19 Aug 2025 23:37:24 +0900 Subject: [PATCH 066/119] =?UTF-8?q?feat(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80/=EB=8B=B5=EA=B8=80=20AP?= =?UTF-8?q?I=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 댓글 리스트에 댓글 개수 추가 --- .../chat/character/comment/CharacterCommentDto.kt | 9 +++++++++ .../chat/character/comment/CharacterCommentService.kt | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index 437fdf0..769719c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -43,3 +43,12 @@ data class CharacterCommentRepliesResponse( val original: CharacterCommentResponse, val replies: List ) + +// 댓글 리스트 조회 Response 컨테이너 +// - 전체 댓글 개수(totalCount) +// - 댓글 목록(comments) + +data class CharacterCommentListResponse( + val totalCount: Int, + val comments: List +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 64b6e65..7619ad7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -83,13 +83,18 @@ class CharacterCommentService( } @Transactional(readOnly = true) - fun listComments(imageHost: String, characterId: Long, limit: Int = 20): List { + fun listComments(imageHost: String, characterId: Long, limit: Int = 20): CharacterCommentListResponse { val pageable = PageRequest.of(0, limit) val comments = commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( characterId, pageable ) - return comments.map { toCommentResponse(imageHost, it) } + val items = comments.map { toCommentResponse(imageHost, it) } + val total = getTotalCommentCount(characterId) + return CharacterCommentListResponse( + totalCount = total, + comments = items + ) } @Transactional(readOnly = true) From a05bc369b71439fec2a98701fe67cef5a1a28e26 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 19 Aug 2025 23:57:46 +0900 Subject: [PATCH 067/119] =?UTF-8?q?feat(character-comment):=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80/=EB=8C=80=EB=8C=93=EA=B8=80=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커서를 추가하여 페이징 처리 --- .../comment/CharacterCommentController.kt | 6 +- .../character/comment/CharacterCommentDto.kt | 6 +- .../comment/CharacterCommentRepository.kt | 14 +++++ .../comment/CharacterCommentService.kt | 56 +++++++++++++++---- 4 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt index fadc70f..8b772e6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -54,12 +54,13 @@ class CharacterCommentController( fun listComments( @PathVariable characterId: Long, @RequestParam(required = false, defaultValue = "20") limit: Int, + @RequestParam(required = false) cursor: Long?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - val data = service.listComments(imageHost, characterId, limit) + val data = service.listComments(imageHost, characterId, cursor, limit) ApiResponse.ok(data) } @@ -68,13 +69,14 @@ class CharacterCommentController( @PathVariable characterId: Long, @PathVariable commentId: Long, @RequestParam(required = false, defaultValue = "20") limit: Int, + @RequestParam(required = false) cursor: Long?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") // characterId는 서비스 내부 검증(원본 댓글과 캐릭터 일치)에서 검증됨 - val data = service.getReplies(imageHost, commentId, limit) + val data = service.getReplies(imageHost, commentId, cursor, limit) ApiResponse.ok(data) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index 769719c..fd67785 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -41,7 +41,8 @@ data class CharacterReplyResponse( data class CharacterCommentRepliesResponse( val original: CharacterCommentResponse, - val replies: List + val replies: List, + val cursor: Long? ) // 댓글 리스트 조회 Response 컨테이너 @@ -50,5 +51,6 @@ data class CharacterCommentRepliesResponse( data class CharacterCommentListResponse( val totalCount: Int, - val comments: List + val comments: List, + val cursor: Long? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt index e160fc9..d921a06 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt @@ -9,8 +9,22 @@ interface CharacterCommentRepository : JpaRepository { pageable: Pageable ): List + fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc( + chatCharacterId: Long, + id: Long, + pageable: Pageable + ): List + fun countByParent_IdAndIsActiveTrue(parentId: Long): Int + fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List + + fun findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc( + parentId: Long, + id: Long, + pageable: Pageable + ): List + fun findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(chatCharacterId: Long): CharacterComment? // 전체(상위+답글) 활성 댓글 총 개수 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 7619ad7..ff54dd3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -83,31 +83,67 @@ class CharacterCommentService( } @Transactional(readOnly = true) - fun listComments(imageHost: String, characterId: Long, limit: Int = 20): CharacterCommentListResponse { + fun listComments( + imageHost: String, + characterId: Long, + cursor: Long?, + limit: Int = 20 + ): CharacterCommentListResponse { val pageable = PageRequest.of(0, limit) - val comments = commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( - characterId, - pageable - ) + val comments = if (cursor == null) { + commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( + characterId, + pageable + ) + } else { + commentRepository.findByChatCharacter_IdAndIsActiveTrueAndParentIsNullAndIdLessThanOrderByCreatedAtDesc( + characterId, + cursor, + pageable + ) + } + val items = comments.map { toCommentResponse(imageHost, it) } val total = getTotalCommentCount(characterId) + val nextCursor = if (items.size == limit) items.lastOrNull()?.commentId else null + return CharacterCommentListResponse( totalCount = total, - comments = items + comments = items, + cursor = nextCursor ) } @Transactional(readOnly = true) - fun getReplies(imageHost: String, commentId: Long, limit: Int = 20): CharacterCommentRepliesResponse { - val original = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } + fun getReplies( + imageHost: String, + commentId: Long, + cursor: Long?, + limit: Int = 20 + ): CharacterCommentRepliesResponse { + val original = commentRepository.findById(commentId).orElseThrow { + SodaException("댓글을 찾을 수 없습니다.") + } if (!original.isActive) throw SodaException("비활성화된 댓글입니다.") val pageable = PageRequest.of(0, limit) - val replies = commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable) + val replies = if (cursor == null) { + commentRepository.findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(commentId, pageable) + } else { + commentRepository.findByParent_IdAndIsActiveTrueAndIdLessThanOrderByCreatedAtDesc( + commentId, + cursor, + pageable + ) + } + + val items = replies.map { toReplyResponse(imageHost, it) } + val nextCursor = if (items.size == limit) items.lastOrNull()?.replyId else null return CharacterCommentRepliesResponse( original = toCommentResponse(imageHost, original, 0), - replies = replies.map { toReplyResponse(imageHost, it) } + replies = items, + cursor = nextCursor ) } From 1444afaae2c5b7693a968da568a7fedfa73af2f3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 20 Aug 2025 00:13:13 +0900 Subject: [PATCH 068/119] =?UTF-8?q?feat(chat-character-comment):=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=8B=A0=EA=B3=A0=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 API: 본인 댓글에 대해 soft delete 처리 - 신고 API: 신고 내용을 그대로 저장하는 CharacterCommentReport 엔티티/리포지토리 도입 - Controller: 삭제, 신고 엔드포인트 추가 및 인증/본인인증 체크 - Service: 비즈니스 로직 구현 및 예외 처리 강화 왜: 캐릭터 댓글 관리 기능 요구사항(삭제/신고)을 충족하기 위함 무엇: 엔드포인트, 서비스 로직, DTO 및 JPA 엔티티/리포지토리 추가 --- .../comment/CharacterCommentController.kt | 26 +++++++++++++++++++ .../character/comment/CharacterCommentDto.kt | 5 ++++ .../comment/CharacterCommentReport.kt | 25 ++++++++++++++++++ .../CharacterCommentReportRepository.kt | 5 ++++ .../comment/CharacterCommentService.kt | 26 ++++++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReport.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReportRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt index 8b772e6..8036169 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentController.kt @@ -5,6 +5,7 @@ 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.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -79,4 +80,29 @@ class CharacterCommentController( val data = service.getReplies(imageHost, commentId, cursor, limit) ApiResponse.ok(data) } + + @DeleteMapping("/{characterId}/comments/{commentId}") + fun deleteComment( + @PathVariable characterId: Long, + @PathVariable commentId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + service.deleteComment(characterId, commentId, member) + ApiResponse.ok(true, "댓글이 삭제되었습니다.") + } + + @PostMapping("/{characterId}/comments/{commentId}/reports") + fun reportComment( + @PathVariable characterId: Long, + @PathVariable commentId: Long, + @RequestBody request: ReportCharacterCommentRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + service.reportComment(characterId, commentId, member, request.content) + ApiResponse.ok(true, "신고가 접수되었습니다.") + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index fd67785..ba9d66a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -54,3 +54,8 @@ data class CharacterCommentListResponse( val comments: List, val cursor: Long? ) + +// 신고 Request +data class ReportCharacterCommentRequest( + val content: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReport.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReport.kt new file mode 100644 index 0000000..96c2955 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReport.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "character_comment_report") +data class CharacterCommentReport( + @Column(columnDefinition = "TEXT", nullable = false) + val content: String +) : BaseEntity() { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + var comment: CharacterComment? = null + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + var member: Member? = null +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReportRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReportRepository.kt new file mode 100644 index 0000000..229fd6f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentReportRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.chat.character.comment + +import org.springframework.data.jpa.repository.JpaRepository + +interface CharacterCommentReportRepository : JpaRepository diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index ff54dd3..ead1eeb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -11,7 +11,8 @@ import java.time.ZoneId @Service class CharacterCommentService( private val chatCharacterRepository: ChatCharacterRepository, - private val commentRepository: CharacterCommentRepository + private val commentRepository: CharacterCommentRepository, + private val reportRepository: CharacterCommentReportRepository ) { private fun profileUrl(imageHost: String, profileImage: String?): String { @@ -157,4 +158,27 @@ class CharacterCommentService( fun getTotalCommentCount(characterId: Long): Int { return commentRepository.countByChatCharacter_IdAndIsActiveTrue(characterId) } + + @Transactional + fun deleteComment(characterId: Long, commentId: Long, member: Member) { + val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } + if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") + if (!comment.isActive) return + val ownerId = comment.member?.id ?: throw SodaException("유효하지 않은 댓글입니다.") + if (ownerId != member.id) throw SodaException("삭제 권한이 없습니다.") + comment.isActive = false + commentRepository.save(comment) + } + + @Transactional + fun reportComment(characterId: Long, commentId: Long, member: Member, content: String) { + val comment = commentRepository.findById(commentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") } + if (comment.chatCharacter?.id != characterId) throw SodaException("잘못된 요청입니다.") + if (content.isBlank()) throw SodaException("신고 내용을 입력해주세요.") + + val report = CharacterCommentReport(content = content) + report.comment = comment + report.member = member + reportRepository.save(report) + } } From 1c0d40aed9e76b6539164b1daa6ab3e5f5d671ad Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 20 Aug 2025 00:26:11 +0900 Subject: [PATCH 069/119] =?UTF-8?q?feat(chat-character-comment):=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=EC=97=90=20?= =?UTF-8?q?=EA=B8=80=EC=93=B4=EC=9D=B4=20ID=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/comment/CharacterCommentDto.kt | 2 ++ .../sodalive/chat/character/comment/CharacterCommentService.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index ba9d66a..ad53dc2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -15,6 +15,7 @@ data class CreateCharacterCommentRequest( data class CharacterCommentResponse( val commentId: Long, + val memberId: Long, val memberProfileImage: String, val memberNickname: String, val createdAt: Long, @@ -30,6 +31,7 @@ data class CharacterCommentResponse( data class CharacterReplyResponse( val replyId: Long, + val memberId: Long, val memberProfileImage: String, val memberNickname: String, val createdAt: Long diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index ead1eeb..135288d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -35,6 +35,7 @@ class CharacterCommentService( val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") return CharacterCommentResponse( commentId = entity.id!!, + memberId = member.id!!, memberProfileImage = profileUrl(imageHost, member.profileImage), memberNickname = member.nickname, createdAt = toEpochMilli(entity.createdAt), @@ -47,6 +48,7 @@ class CharacterCommentService( val member = entity.member ?: throw SodaException("유효하지 않은 댓글입니다.") return CharacterReplyResponse( replyId = entity.id!!, + memberId = member.id!!, memberProfileImage = profileUrl(imageHost, member.profileImage), memberNickname = member.nickname, createdAt = toEpochMilli(entity.createdAt) From aeab6eddc2c79de33914da83a990396fb4fc64ff Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 20 Aug 2025 00:37:39 +0900 Subject: [PATCH 070/119] =?UTF-8?q?feat(chat-character-comment):=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EB=8C=93=EA=B8=80=EC=9D=98=20?= =?UTF-8?q?=EB=8B=B5=EA=B8=80=EC=97=90=20=EB=8C=93=EA=B8=80=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/comment/CharacterCommentDto.kt | 3 ++- .../sodalive/chat/character/comment/CharacterCommentService.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt index ad53dc2..d35f2cb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentDto.kt @@ -34,7 +34,8 @@ data class CharacterReplyResponse( val memberId: Long, val memberProfileImage: String, val memberNickname: String, - val createdAt: Long + val createdAt: Long, + val comment: String ) // 댓글의 답글 조회 Response 컨테이너 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index 135288d..ecf6234 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -51,7 +51,8 @@ class CharacterCommentService( memberId = member.id!!, memberProfileImage = profileUrl(imageHost, member.profileImage), memberNickname = member.nickname, - createdAt = toEpochMilli(entity.createdAt) + createdAt = toEpochMilli(entity.createdAt), + comment = entity.comment ) } From ca27903e45778c729df5eed6506d988bfc74dad2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 20 Aug 2025 15:39:52 +0900 Subject: [PATCH 071/119] =?UTF-8?q?fix(character-comment):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=83=81=EC=84=B8=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=20=EB=B0=8F=20=EC=B5=9C=EC=8B=A0=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EC=A4=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최신 댓글 조회 시 원댓글(Parent=null)만 대상으로 조회하도록 Repository 메서드 및 Service 로직 변경 - 총 댓글 수를 "활성 원댓글 + 활성 부모를 가진 활성 답글"로 계산하여, 삭제된 원댓글의 답글은 집계에서 제외되도록 수정 --- .../character/comment/CharacterCommentRepository.kt | 12 +++++++++--- .../character/comment/CharacterCommentService.kt | 13 ++++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt index d921a06..7f12ea8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentRepository.kt @@ -25,8 +25,14 @@ interface CharacterCommentRepository : JpaRepository { pageable: Pageable ): List - fun findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(chatCharacterId: Long): CharacterComment? + // 최신 원댓글만 조회 + fun findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( + chatCharacterId: Long + ): CharacterComment? - // 전체(상위+답글) 활성 댓글 총 개수 - fun countByChatCharacter_IdAndIsActiveTrue(chatCharacterId: Long): Int + // 활성 원댓글 수 + fun countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(chatCharacterId: Long): Int + + // 활성 부모를 가진 활성 답글 수 (부모가 null인 경우 제외됨) + fun countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(chatCharacterId: Long): Int } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt index ecf6234..25d186a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/comment/CharacterCommentService.kt @@ -153,13 +153,20 @@ class CharacterCommentService( @Transactional(readOnly = true) fun getLatestComment(imageHost: String, characterId: Long): CharacterCommentResponse? { - val last = commentRepository.findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(characterId) - return last?.let { toCommentResponse(imageHost, it) } + return commentRepository + .findFirstByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc(characterId) + ?.let { toCommentResponse(imageHost, it) } } @Transactional(readOnly = true) fun getTotalCommentCount(characterId: Long): Int { - return commentRepository.countByChatCharacter_IdAndIsActiveTrue(characterId) + // 활성 원댓글 수 + 활성 부모를 가진 활성 답글 수 + val originalCount = commentRepository + .countByChatCharacter_IdAndIsActiveTrueAndParentIsNull(characterId) + val replyWithActiveParentCount = commentRepository + .countByChatCharacter_IdAndIsActiveTrueAndParent_IsActiveTrue(characterId) + + return originalCount + replyWithActiveParentCount } @Transactional From dd6849b8404eb986780396c342390eca61b0c956 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 03:33:42 +0900 Subject: [PATCH 072/119] =?UTF-8?q?feat(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록, 리스트, 상세, 트리거 단어 업데이트, 삭제 기능 추가 --- .../image/AdminCharacterImageController.kt | 125 ++++++++++++++++++ .../character/image/dto/CharacterImageDtos.kt | 54 ++++++++ .../aws/cloudfront/ImageContentCloudFront.kt | 48 +++++++ .../chat/character/image/CharacterImage.kt | 35 +++++ .../image/CharacterImageRepository.kt | 16 +++ .../character/image/CharacterImageService.kt | 99 ++++++++++++++ .../character/image/CharacterImageTrigger.kt | 24 ++++ .../image/CharacterImageTriggerMapping.kt | 21 +++ .../image/CharacterImageTriggerRepository.kt | 9 ++ 9 files changed, 431 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt new file mode 100644 index 0000000..2c181c3 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.admin.chat.character.image + +import com.amazonaws.services.s3.model.ObjectMetadata +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest +import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest +import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService +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 + +@RestController +@RequestMapping("/admin/chat/character/image") +@PreAuthorize("hasRole('ADMIN')") +class AdminCharacterImageController( + private val imageService: CharacterImageService, + private val s3Uploader: S3Uploader, + private val imageCloudFront: ImageContentCloudFront, + + @Value("\${cloud.aws.s3.content-bucket}") + private val s3Bucket: String +) { + + @GetMapping("/list") + fun list(@RequestParam characterId: Long) = run { + val expiration = 5L * 60L * 1000L // 5분 + val list = imageService.listActiveByCharacter(characterId) + .map { img -> + val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) + AdminCharacterImageResponse.fromWithUrl(img, signedUrl) + } + ApiResponse.ok(list) + } + + @GetMapping("/{imageId}") + fun detail(@PathVariable imageId: Long) = run { + val img = imageService.getById(imageId) + val expiration = 5L * 60L * 1000L // 5분 + val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) + ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl)) + } + + @PostMapping("/register") + fun register( + @RequestPart("image") image: MultipartFile, + @RequestPart("request") requestString: String + ) = run { + val objectMapper = ObjectMapper() + val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) + + // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 + val tempKey = buildS3Key(characterId = request.characterId) + val imagePath = saveImage(tempKey, image) + + imageService.registerImage( + characterId = request.characterId, + imagePath = imagePath, + price = request.price, + isAdult = request.isAdult, + triggers = request.triggers ?: emptyList() + ) + + ApiResponse.ok(null) + } + + @PutMapping("/{imageId}/triggers") + fun updateTriggers( + @PathVariable imageId: Long, + @RequestBody request: UpdateCharacterImageTriggersRequest + ) = run { + imageService.updateTriggers(imageId, request.triggers ?: emptyList()) + + ApiResponse.ok(null) + } + + @DeleteMapping("/{imageId}") + fun delete(@PathVariable imageId: Long) = run { + imageService.deleteImage(imageId) + ApiResponse.ok(null, "이미지가 삭제되었습니다.") + } + + @PutMapping("/orders") + fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run { + if (request.characterId == null) throw SodaException("characterId는 필수입니다") + imageService.updateOrders(request.characterId, request.ids) + ApiResponse.ok(null, "정렬 순서가 변경되었습니다.") + } + + private fun buildS3Key(characterId: Long): String { + val fileName = generateFileName("character-image") + return "characters/$characterId/images/$fileName" + } + + private fun saveImage(filePath: String, image: MultipartFile): String { + try { + val metadata = ObjectMetadata() + metadata.contentLength = image.size + return s3Uploader.upload( + inputStream = image.inputStream, + bucket = s3Bucket, + filePath = filePath, + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("이미지 저장에 실패했습니다: ${e.message}") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt new file mode 100644 index 0000000..f007a02 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.admin.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage + +// 요청 DTOs + +data class RegisterCharacterImageRequest( + @JsonProperty("characterId") val characterId: Long, + @JsonProperty("price") val price: Long, + @JsonProperty("isAdult") val isAdult: Boolean = false, + @JsonProperty("triggers") val triggers: List? = null +) + +data class UpdateCharacterImageTriggersRequest( + @JsonProperty("triggers") val triggers: List? = null +) + +data class UpdateCharacterImageOrdersRequest( + @JsonProperty("characterId") val characterId: Long?, + @JsonProperty("ids") val ids: List +) + +// 응답 DTOs + +data class AdminCharacterImageResponse( + val id: Long, + val characterId: Long, + val price: Long, + val isAdult: Boolean, + val sortOrder: Int, + val active: Boolean, + val imageUrl: String, + val triggers: List +) { + companion object { + fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse { + return base(entity, signedUrl) + } + + private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse { + return AdminCharacterImageResponse( + id = entity.id!!, + characterId = entity.chatCharacter.id!!, + price = entity.price, + isAdult = entity.isAdult, + sortOrder = entity.sortOrder, + active = entity.isActive, + imageUrl = url, + triggers = entity.triggerMappings.map { it.tag.word } + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt new file mode 100644 index 0000000..c8858df --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.aws.cloudfront + +import com.amazonaws.services.cloudfront.CloudFrontUrlSigner +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.nio.file.Files +import java.nio.file.Paths +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Date + +/** + * 이미지(CloudFront) 서명 URL 생성기 + * - cloud.aws.cloud-front.* 설정을 사용 + */ +@Component +class ImageContentCloudFront( + @Value("\${cloud.aws.cloud-front.host}") + private val cloudfrontDomain: String, + + @Value("\${cloud.aws.cloud-front.private-key-file-path}") + private val privateKeyFilePath: String, + + @Value("\${cloud.aws.cloud-front.key-pair-id}") + private val keyPairId: String +) { + fun generateSignedURL( + resourcePath: String, + expirationTimeMillis: Long + ): String { + val privateKey = loadPrivateKey(privateKeyFilePath) + return CloudFrontUrlSigner.getSignedURLWithCannedPolicy( + "$cloudfrontDomain/$resourcePath", + keyPairId, + privateKey, + Date(System.currentTimeMillis() + expirationTimeMillis) + ) + } + + private fun loadPrivateKey(resourceName: String): PrivateKey { + val path = Paths.get(resourceName) + val bytes = Files.readAllBytes(path) + val keySpec = PKCS8EncodedKeySpec(bytes) + val keyFactory = KeyFactory.getInstance("RSA") + return keyFactory.generatePrivate(keySpec) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt new file mode 100644 index 0000000..350c7a2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +class CharacterImage( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id") + var chatCharacter: ChatCharacter, + + // 이미지 경로 (S3 key) + var imagePath: String, + + // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) + var price: Long = 0L, + + // 성인 이미지 여부 (본인인증 필요) + var isAdult: Boolean = false, + + // 갤러리/관리자 노출 순서 (낮을수록 먼저) + var sortOrder: Int = 0, + + // 활성화 여부 (소프트 삭제) + var isActive: Boolean = true +) : BaseEntity() { + @OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true) + var triggerMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt new file mode 100644 index 0000000..3c6de00 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface CharacterImageRepository : JpaRepository { + fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List + + @Query( + "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + + "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" + ) + fun findMaxSortOrderByCharacterId(characterId: Long): Int +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt new file mode 100644 index 0000000..6cf8768 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CharacterImageService( + private val characterRepository: ChatCharacterRepository, + private val imageRepository: CharacterImageRepository, + private val triggerTagRepository: CharacterImageTriggerRepository +) { + + fun listActiveByCharacter(characterId: Long): List { + return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) + } + + fun getById(id: Long): CharacterImage = + imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } + + @Transactional + fun registerImage( + characterId: Long, + imagePath: String, + price: Long, + isAdult: Boolean, + triggers: List + ): CharacterImage { + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + + if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") + + val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 + val entity = CharacterImage( + chatCharacter = character, + imagePath = imagePath, + price = price, + isAdult = isAdult, + sortOrder = nextOrder, + isActive = true + ) + val saved = imageRepository.save(entity) + applyTriggers(saved, triggers) + return saved + } + + /** + * 수정은 트리거만 가능 + */ + @Transactional + fun updateTriggers(imageId: Long, triggers: List): CharacterImage { + val image = getById(imageId) + if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId") + applyTriggers(image, triggers) + return image + } + + private fun applyTriggers(image: CharacterImage, triggers: List) { + // 입력 트리거 정규화 + val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet() + + // 현재 매핑 단어 집합 + val currentMappings = image.triggerMappings + val currentWords = currentMappings.map { it.tag.word }.toSet() + + // 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어) + val toRemove = currentMappings.filter { it.tag.word !in newWords } + currentMappings.removeAll(toRemove) + + // 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어) + val toAdd = newWords.minus(currentWords) + toAdd.forEach { w -> + val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w)) + currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag)) + } + } + + @Transactional + fun deleteImage(imageId: Long) { + val image = getById(imageId) + image.isActive = false + } + + @Transactional + fun updateOrders(characterId: Long, ids: List): List { + // 동일 캐릭터 소속 검증 및 순서 재지정 + val updated = mutableListOf() + ids.forEachIndexed { idx, id -> + val img = getById(id) + if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id") + if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id") + img.sortOrder = idx + 1 + updated.add(img) + } + return updated + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt new file mode 100644 index 0000000..4335e57 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTrigger.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 캐릭터 이미지 트리거 "태그" 엔티티 + * - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다. + * - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다. + */ +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])]) +class CharacterImageTrigger( + @Column(nullable = false) + var word: String +) : BaseEntity() { + @OneToMany(mappedBy = "tag", fetch = FetchType.LAZY) + var mappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt new file mode 100644 index 0000000..746cbef --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerMapping.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티 + */ +@Entity +class CharacterImageTriggerMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id") + var characterImage: CharacterImage, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + var tag: CharacterImageTrigger +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt new file mode 100644 index 0000000..537f308 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageTriggerRepository.kt @@ -0,0 +1,9 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface CharacterImageTriggerRepository : JpaRepository { + fun findByWord(word: String): CharacterImageTrigger? +} From 2a30b28e43f8d6657be1ce68a5cf165bef237e26 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 04:00:02 +0900 Subject: [PATCH 073/119] =?UTF-8?q?feat(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록시 블러 이미지를 생성하여 저장하는 기능 추가 --- .../image/AdminCharacterImageController.kt | 53 +++++++-- .../chat/character/image/CharacterImage.kt | 5 +- .../character/image/CharacterImageService.kt | 2 + .../vividnext/sodalive/utils/ImageBlurUtil.kt | 102 ++++++++++++++++++ 4 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 2c181c3..76503c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -35,7 +35,10 @@ class AdminCharacterImageController( private val imageCloudFront: ImageContentCloudFront, @Value("\${cloud.aws.s3.content-bucket}") - private val s3Bucket: String + private val s3Bucket: String, + + @Value("\${cloud.aws.s3.bucket}") + private val freeBucket: String ) { @GetMapping("/list") @@ -65,13 +68,19 @@ class AdminCharacterImageController( val objectMapper = ObjectMapper() val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) - // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 - val tempKey = buildS3Key(characterId = request.characterId) - val imagePath = saveImage(tempKey, image) + // 업로드 키 생성 + val s3Key = buildS3Key(characterId = request.characterId) + + // 원본 저장 (content-bucket) + val imagePath = saveImageToBucket(s3Key, image, s3Bucket) + + // 블러 생성 및 저장 (무료 이미지 버킷) + val blurImagePath = saveBlurImageToBucket(s3Key, image, freeBucket) imageService.registerImage( characterId = request.characterId, imagePath = imagePath, + blurImagePath = blurImagePath, price = request.price, isAdult = request.isAdult, triggers = request.triggers ?: emptyList() @@ -108,13 +117,13 @@ class AdminCharacterImageController( return "characters/$characterId/images/$fileName" } - private fun saveImage(filePath: String, image: MultipartFile): String { + private fun saveImageToBucket(filePath: String, image: MultipartFile, bucket: String): String { try { val metadata = ObjectMetadata() metadata.contentLength = image.size return s3Uploader.upload( inputStream = image.inputStream, - bucket = s3Bucket, + bucket = bucket, filePath = filePath, metadata = metadata ) @@ -122,4 +131,36 @@ class AdminCharacterImageController( throw SodaException("이미지 저장에 실패했습니다: ${e.message}") } } + + private fun saveBlurImageToBucket(filePath: String, image: MultipartFile, bucket: String): String { + try { + // 멀티파트를 BufferedImage로 읽기 + val bytes = image.bytes + val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) + ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") + val blurred = kr.co.vividnext.sodalive.utils.ImageBlurUtil.blur(bimg, 12) + + // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 + val baos = java.io.ByteArrayOutputStream() + val format = when (image.contentType?.lowercase()) { + "image/png" -> "png" + else -> "jpg" + } + javax.imageio.ImageIO.write(blurred, format, baos) + val inputStream = java.io.ByteArrayInputStream(baos.toByteArray()) + + val metadata = ObjectMetadata() + metadata.contentLength = baos.size().toLong() + metadata.contentType = image.contentType ?: if (format == "png") "image/png" else "image/jpeg" + + return s3Uploader.upload( + inputStream = inputStream, + bucket = bucket, + filePath = filePath, + metadata = metadata + ) + } catch (e: Exception) { + throw SodaException("블러 이미지 저장에 실패했습니다: ${e.message}") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt index 350c7a2..e5aaa0b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt @@ -15,9 +15,12 @@ class CharacterImage( @JoinColumn(name = "character_id") var chatCharacter: ChatCharacter, - // 이미지 경로 (S3 key) + // 원본 이미지 경로 (S3 key - content-bucket) var imagePath: String, + // 블러 이미지 경로 (S3 key - free/public bucket) + var blurImagePath: String, + // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) var price: Long = 0L, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index 6cf8768..cceb7f3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -23,6 +23,7 @@ class CharacterImageService( fun registerImage( characterId: Long, imagePath: String, + blurImagePath: String, price: Long, isAdult: Boolean, triggers: List @@ -36,6 +37,7 @@ class CharacterImageService( val entity = CharacterImage( chatCharacter = character, imagePath = imagePath, + blurImagePath = blurImagePath, price = price, isAdult = isAdult, sortOrder = nextOrder, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt new file mode 100644 index 0000000..6b1d066 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -0,0 +1,102 @@ +package kr.co.vividnext.sodalive.utils + +import java.awt.image.BufferedImage +import kotlin.math.exp +import kotlin.math.max +import kotlin.math.min + +/** + * 가우시안 커널 기반 블러 유틸리티 + * - 반경(radius)에 따라 커널 크기(2*radius+1) 생성 + * - 시그마는 관례적으로 radius/3.0 적용 + * - 수평/수직 분리 합성곱으로 품질과 성능 확보 + */ +object ImageBlurUtil { + fun blur(src: BufferedImage, radius: Int = 10): BufferedImage { + require(radius > 0) { "radius must be > 0" } + val w = src.width + val h = src.height + val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + + // 가우시안 1D 커널 생성 및 정규화 + val sigma = radius / 3.0 + val kernel = gaussianKernel(radius, sigma) + + // 중간 버퍼 + val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + + // 수평 합성곱 + for (y in 0 until h) { + for (x in 0 until w) { + var aAcc = 0.0 + var rAcc = 0.0 + var gAcc = 0.0 + var bAcc = 0.0 + for (k in -radius..radius) { + val xx = clamp(x + k, 0, w - 1) + val rgb = src.getRGB(xx, y) + val a = (rgb ushr 24) and 0xFF + val r = (rgb ushr 16) and 0xFF + val g = (rgb ushr 8) and 0xFF + val b = rgb and 0xFF + val wgt = kernel[k + radius] + aAcc += a * wgt + rAcc += r * wgt + gAcc += g * wgt + bAcc += b * wgt + } + val a = aAcc.toInt().coerceIn(0, 255) + val r = rAcc.toInt().coerceIn(0, 255) + val g = gAcc.toInt().coerceIn(0, 255) + val b = bAcc.toInt().coerceIn(0, 255) + temp.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) + } + } + + // 수직 합성곱 + for (x in 0 until w) { + for (y in 0 until h) { + var aAcc = 0.0 + var rAcc = 0.0 + var gAcc = 0.0 + var bAcc = 0.0 + for (k in -radius..radius) { + val yy = clamp(y + k, 0, h - 1) + val rgb = temp.getRGB(x, yy) + val a = (rgb ushr 24) and 0xFF + val r = (rgb ushr 16) and 0xFF + val g = (rgb ushr 8) and 0xFF + val b = rgb and 0xFF + val wgt = kernel[k + radius] + aAcc += a * wgt + rAcc += r * wgt + gAcc += g * wgt + bAcc += b * wgt + } + val a = aAcc.toInt().coerceIn(0, 255) + val r = rAcc.toInt().coerceIn(0, 255) + val g = gAcc.toInt().coerceIn(0, 255) + val b = bAcc.toInt().coerceIn(0, 255) + dst.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) + } + } + return dst + } + + private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray { + val size = 2 * radius + 1 + val kernel = DoubleArray(size) + val sigma2 = 2.0 * sigma * sigma + var sum = 0.0 + for (i in -radius..radius) { + val v = exp(-(i * i) / sigma2) + kernel[i + radius] = v + sum += v + } + // 정규화 + for (i in kernel.indices) kernel[i] /= sum + return kernel + } + + private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) +} From c8841856c05ac6ca1c6114ca1981153312139c5d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 04:01:47 +0900 Subject: [PATCH 074/119] =?UTF-8?q?fix(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - triggers가 null이거나 빈 리스트이면 수정없이 실행종료 --- .../chat/character/image/AdminCharacterImageController.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 76503c4..1cfed98 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -94,7 +94,9 @@ class AdminCharacterImageController( @PathVariable imageId: Long, @RequestBody request: UpdateCharacterImageTriggersRequest ) = run { - imageService.updateTriggers(imageId, request.triggers ?: emptyList()) + if (!request.triggers.isNullOrEmpty()) { + imageService.updateTriggers(imageId, request.triggers) + } ApiResponse.ok(null) } From 8451cdfb80cb8a4612f1ac68cfc14430c5dbaf3f Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 04:07:25 +0900 Subject: [PATCH 075/119] =?UTF-8?q?fix(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B0=80?= =?UTF-8?q?=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 단독 구매 가격과 메시지를 통한 구매 가겨으로 분리 --- .../character/image/AdminCharacterImageController.kt | 3 ++- .../admin/chat/character/image/dto/CharacterImageDtos.kt | 9 ++++++--- .../sodalive/chat/character/image/CharacterImage.kt | 7 +++++-- .../chat/character/image/CharacterImageService.kt | 8 ++++++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 1cfed98..b291e27 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -81,7 +81,8 @@ class AdminCharacterImageController( characterId = request.characterId, imagePath = imagePath, blurImagePath = blurImagePath, - price = request.price, + imagePriceCan = request.imagePriceCan, + messagePriceCan = request.messagePriceCan, isAdult = request.isAdult, triggers = request.triggers ?: emptyList() ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt index f007a02..21ec681 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt @@ -7,7 +7,8 @@ import kr.co.vividnext.sodalive.chat.character.image.CharacterImage data class RegisterCharacterImageRequest( @JsonProperty("characterId") val characterId: Long, - @JsonProperty("price") val price: Long, + @JsonProperty("imagePriceCan") val imagePriceCan: Long, + @JsonProperty("messagePriceCan") val messagePriceCan: Long, @JsonProperty("isAdult") val isAdult: Boolean = false, @JsonProperty("triggers") val triggers: List? = null ) @@ -26,7 +27,8 @@ data class UpdateCharacterImageOrdersRequest( data class AdminCharacterImageResponse( val id: Long, val characterId: Long, - val price: Long, + val imagePriceCan: Long, + val messagePriceCan: Long, val isAdult: Boolean, val sortOrder: Int, val active: Boolean, @@ -42,7 +44,8 @@ data class AdminCharacterImageResponse( return AdminCharacterImageResponse( id = entity.id!!, characterId = entity.chatCharacter.id!!, - price = entity.price, + imagePriceCan = entity.imagePriceCan, + messagePriceCan = entity.messagePriceCan, isAdult = entity.isAdult, sortOrder = entity.sortOrder, active = entity.isActive, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt index e5aaa0b..d563a0c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImage.kt @@ -21,8 +21,11 @@ class CharacterImage( // 블러 이미지 경로 (S3 key - free/public bucket) var blurImagePath: String, - // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) - var price: Long = 0L, + // 이미지 단독 구매 가격 (단위: can) + var imagePriceCan: Long = 0L, + + // 메시지를 통한 가격 (단위: can) + var messagePriceCan: Long = 0L, // 성인 이미지 여부 (본인인증 필요) var isAdult: Boolean = false, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index cceb7f3..8ea064e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -24,13 +24,16 @@ class CharacterImageService( characterId: Long, imagePath: String, blurImagePath: String, - price: Long, + imagePriceCan: Long, + messagePriceCan: Long, isAdult: Boolean, triggers: List ): CharacterImage { val character = characterRepository.findById(characterId) .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + if (imagePriceCan < 0 || messagePriceCan < 0) throw SodaException("가격은 0 can 이상이어야 합니다.") + if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 @@ -38,7 +41,8 @@ class CharacterImageService( chatCharacter = character, imagePath = imagePath, blurImagePath = blurImagePath, - price = price, + imagePriceCan = imagePriceCan, + messagePriceCan = messagePriceCan, isAdult = isAdult, sortOrder = nextOrder, isActive = true From 13fd262c94d3438d4526c87edbd14d83ed6a7a65 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 17:39:19 +0900 Subject: [PATCH 076/119] =?UTF-8?q?feat(chat-character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B3=B4=EC=9C=A0=20=ED=8C=90=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/can/CanService.kt | 2 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 4 +- .../co/vividnext/sodalive/can/use/UseCan.kt | 12 ++++ .../sodalive/can/use/UseCanRepository.kt | 34 ++++++++- .../image/CharacterImageController.kt | 71 +++++++++++++++++++ .../image/CharacterImageRepository.kt | 9 +++ .../character/image/CharacterImageService.kt | 39 +++++++++- .../image/dto/CharacterImageListDtos.kt | 18 +++++ 8 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 33466a4..c77366a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -72,6 +72,8 @@ class CanService(private val repository: CanRepository) { CanUsage.ORDER_CONTENT -> "[콘텐츠 구매] ${it.audioContent!!.title}" CanUsage.PAID_COMMUNITY_POST -> "[게시글 보기] ${it.communityPost?.member?.nickname ?: ""}" CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" + CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" + CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" } val createdAt = it.createdAt!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 0b26698..976845c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -9,5 +9,7 @@ enum class CanUsage { SPIN_ROULETTE, PAID_COMMUNITY_POST, ALARM_SLOT, - AUDITION_VOTE + AUDITION_VOTE, + CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) + CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt index 5a879f4..3d0fc46 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.can.use import kr.co.vividnext.sodalive.audition.AuditionApplicant +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage +import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.order.Order @@ -58,6 +60,16 @@ data class UseCan( @JoinColumn(name = "audition_applicant_id", nullable = true) var auditionApplicant: AuditionApplicant? = null + // 메시지를 통한 구매 연관 (옵션) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_message_id", nullable = true) + var chatMessage: ChatMessage? = null + + // 캐릭터 이미지 연관 (메시지 구매/단독 구매 공통 사용) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id", nullable = true) + var characterImage: CharacterImage? = null + @OneToMany(mappedBy = "useCan", cascade = [CascadeType.ALL]) val useCanCalculates: MutableList = mutableListOf() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt index 07bdce1..fd8f1dd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanRepository.kt @@ -6,10 +6,22 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository @Repository -interface UseCanRepository : JpaRepository, UseCanQueryRepository +interface UseCanRepository : JpaRepository, UseCanQueryRepository { + // 특정 멤버가 해당 이미지에 대해 구매 이력이 있는지(환불 제외) + fun existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( + memberId: Long, + imageId: Long, + usages: Collection + ): Boolean +} interface UseCanQueryRepository { fun isExistCommunityPostOrdered(postId: Long, memberId: Long): Boolean + fun countPurchasedActiveImagesByCharacter( + memberId: Long, + characterId: Long, + usages: Collection + ): Long } class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : UseCanQueryRepository { @@ -26,4 +38,24 @@ class UseCanQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Use return useCanId != null && useCanId > 0 } + + override fun countPurchasedActiveImagesByCharacter( + memberId: Long, + characterId: Long, + usages: Collection + ): Long { + val count = queryFactory + .selectDistinct(useCan.characterImage.id) + .from(useCan) + .where( + useCan.member.id.eq(memberId) + .and(useCan.isRefund.isFalse) + .and(useCan.characterImage.chatCharacter.id.eq(characterId)) + .and(useCan.characterImage.isActive.isTrue) + .and(useCan.canUsage.`in`(usages)) + ) + .fetch() + .size + return count.toLong() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt new file mode 100644 index 0000000..7f59c75 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -0,0 +1,71 @@ +package kr.co.vividnext.sodalive.chat.character.image + +import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse +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.data.domain.PageRequest +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/character/image") +class CharacterImageController( + private val imageService: CharacterImageService, + private val imageCloudFront: ImageContentCloudFront, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + + @GetMapping("/list") + fun list( + @RequestParam characterId: Long, + @RequestParam(required = false, defaultValue = "0") page: Int, + @RequestParam(required = false, defaultValue = "20") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val pageSize = if (size <= 0) 20 else minOf(size, 20) + val pageable = PageRequest.of(page, pageSize) + + val pageResult = imageService.pageActiveByCharacter(characterId, pageable) + val totalCount = pageResult.totalElements + + // 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장) + val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + + val expiration = 5L * 60L * 1000L // 5분 + val items = pageResult.content.map { img -> + val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!) + val url = if (isOwned) { + imageCloudFront.generateSignedURL(img.imagePath, expiration) + } else { + "$imageHost/${img.blurImagePath}" + } + CharacterImageListItemResponse( + id = img.id!!, + imageUrl = url, + isOwned = isOwned, + imagePriceCan = img.imagePriceCan, + isAdult = img.isAdult, + sortOrder = img.sortOrder + ) + } + + ApiResponse.ok( + CharacterImageListResponse( + totalCount = totalCount, + ownedCount = ownedCount, + items = items + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt index 3c6de00..8337da6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.chat.character.image +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.stereotype.Repository @@ -8,6 +10,13 @@ import org.springframework.stereotype.Repository interface CharacterImageRepository : JpaRepository { fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List + fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( + characterId: Long, + pageable: Pageable + ): Page + + fun countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId: Long, imagePriceCan: Long): Long + @Query( "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index 8ea064e..a97c9df 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -1,7 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.image +// ktlint-disable standard:max-line-length +import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -9,13 +13,46 @@ import org.springframework.transaction.annotation.Transactional class CharacterImageService( private val characterRepository: ChatCharacterRepository, private val imageRepository: CharacterImageRepository, - private val triggerTagRepository: CharacterImageTriggerRepository + private val triggerTagRepository: CharacterImageTriggerRepository, + private val useCanRepository: kr.co.vividnext.sodalive.can.use.UseCanRepository ) { fun listActiveByCharacter(characterId: Long): List { return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) } + // 페이징 조회(활성 이미지) + fun pageActiveByCharacter(characterId: Long, pageable: Pageable): Page { + return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable) + } + + // 구매 이력 + 무료로 계산된 보유 수 + fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long { + val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L) + val purchasedCount = useCanRepository.countPurchasedActiveImagesByCharacter( + memberId, + characterId, + listOf( + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHARACTER_IMAGE_PURCHASE + ) + ) + return freeCount + purchasedCount + } + + fun isOwnedImageByMember(imageId: Long, memberId: Long): Boolean { + // 무료이거나(컨트롤러에서 가격 확인) 구매 이력이 있으면 보유로 판단 + val purchased = useCanRepository.existsByMember_IdAndIsRefundFalseAndCharacterImage_IdAndCanUsageIn( + memberId, + imageId, + listOf( + CanUsage.CHAT_MESSAGE_PURCHASE, + CanUsage.CHARACTER_IMAGE_PURCHASE + ) + ) + return purchased + } + fun getById(id: Long): CharacterImage = imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt new file mode 100644 index 0000000..ad19968 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class CharacterImageListItemResponse( + @JsonProperty("id") val id: Long, + @JsonProperty("imageUrl") val imageUrl: String, + @JsonProperty("isOwned") val isOwned: Boolean, + @JsonProperty("imagePriceCan") val imagePriceCan: Long, + @JsonProperty("isAdult") val isAdult: Boolean, + @JsonProperty("sortOrder") val sortOrder: Int +) + +data class CharacterImageListResponse( + @JsonProperty("totalCount") val totalCount: Long, + @JsonProperty("ownedCount") val ownedCount: Long, + @JsonProperty("items") val items: List +) From 75100cacec16fc481d1a7e6adfa8e761741be451 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 18:09:17 +0900 Subject: [PATCH 077/119] fix: ImageContentCloudFront.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host, key-pair-id, key-file-path 참조 변경 --- .../sodalive/aws/cloudfront/ImageContentCloudFront.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt index c8858df..1a367e0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/aws/cloudfront/ImageContentCloudFront.kt @@ -16,13 +16,13 @@ import java.util.Date */ @Component class ImageContentCloudFront( - @Value("\${cloud.aws.cloud-front.host}") + @Value("\${cloud.aws.content-cloud-front.host}") private val cloudfrontDomain: String, - @Value("\${cloud.aws.cloud-front.private-key-file-path}") + @Value("\${cloud.aws.content-cloud-front.private-key-file-path}") private val privateKeyFilePath: String, - @Value("\${cloud.aws.cloud-front.key-pair-id}") + @Value("\${cloud.aws.content-cloud-front.key-pair-id}") private val keyPairId: String ) { fun generateSignedURL( From 090fc818297f0e21ad695e19a318ea0e152ac1ad Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 19:10:37 +0900 Subject: [PATCH 078/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 적용 범위 radius 10 -> 50 --- src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 6b1d066..31f030d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -12,7 +12,7 @@ import kotlin.math.min * - 수평/수직 분리 합성곱으로 품질과 성능 확보 */ object ImageBlurUtil { - fun blur(src: BufferedImage, radius: Int = 10): BufferedImage { + fun blur(src: BufferedImage, radius: Int = 50): BufferedImage { require(radius > 0) { "radius must be > 0" } val w = src.width val h = src.height From 4bee95c8a692c6367d9a5677272e53defa6769b3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 19:34:23 +0900 Subject: [PATCH 079/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 적용 범위 radius 10 -> 50 --- .../chat/character/image/AdminCharacterImageController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index b291e27..2652163 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -11,6 +11,7 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.utils.ImageBlurUtil import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize @@ -141,7 +142,7 @@ class AdminCharacterImageController( val bytes = image.bytes val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") - val blurred = kr.co.vividnext.sodalive.utils.ImageBlurUtil.blur(bimg, 12) + val blurred = ImageBlurUtil.blur(bimg) // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 val baos = java.io.ByteArrayOutputStream() From abbd73ac0092a0ee99c6172ed0212f748542776d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 19:51:10 +0900 Subject: [PATCH 080/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 처리 방식 변경 --- .../image/AdminCharacterImageController.kt | 2 +- .../vividnext/sodalive/utils/ImageBlurUtil.kt | 205 +++++++++++------- 2 files changed, 122 insertions(+), 85 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 2652163..fc16e25 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -142,7 +142,7 @@ class AdminCharacterImageController( val bytes = image.bytes val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") - val blurred = ImageBlurUtil.blur(bimg) + val blurred = ImageBlurUtil.anonymizeStrong(bimg) // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 val baos = java.io.ByteArrayOutputStream() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 31f030d..09e2fe4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.utils +import java.awt.RenderingHints import java.awt.image.BufferedImage +import java.awt.image.ConvolveOp +import java.awt.image.Kernel import kotlin.math.exp import kotlin.math.max -import kotlin.math.min +import kotlin.math.roundToInt /** * 가우시안 커널 기반 블러 유틸리티 @@ -12,91 +15,125 @@ import kotlin.math.min * - 수평/수직 분리 합성곱으로 품질과 성능 확보 */ object ImageBlurUtil { - fun blur(src: BufferedImage, radius: Int = 50): BufferedImage { - require(radius > 0) { "radius must be > 0" } - val w = src.width - val h = src.height - val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) - - // 가우시안 1D 커널 생성 및 정규화 - val sigma = radius / 3.0 - val kernel = gaussianKernel(radius, sigma) - - // 중간 버퍼 - val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) - - // 수평 합성곱 - for (y in 0 until h) { - for (x in 0 until w) { - var aAcc = 0.0 - var rAcc = 0.0 - var gAcc = 0.0 - var bAcc = 0.0 - for (k in -radius..radius) { - val xx = clamp(x + k, 0, w - 1) - val rgb = src.getRGB(xx, y) - val a = (rgb ushr 24) and 0xFF - val r = (rgb ushr 16) and 0xFF - val g = (rgb ushr 8) and 0xFF - val b = rgb and 0xFF - val wgt = kernel[k + radius] - aAcc += a * wgt - rAcc += r * wgt - gAcc += g * wgt - bAcc += b * wgt - } - val a = aAcc.toInt().coerceIn(0, 255) - val r = rAcc.toInt().coerceIn(0, 255) - val g = gAcc.toInt().coerceIn(0, 255) - val b = bAcc.toInt().coerceIn(0, 255) - temp.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) - } - } - - // 수직 합성곱 - for (x in 0 until w) { - for (y in 0 until h) { - var aAcc = 0.0 - var rAcc = 0.0 - var gAcc = 0.0 - var bAcc = 0.0 - for (k in -radius..radius) { - val yy = clamp(y + k, 0, h - 1) - val rgb = temp.getRGB(x, yy) - val a = (rgb ushr 24) and 0xFF - val r = (rgb ushr 16) and 0xFF - val g = (rgb ushr 8) and 0xFF - val b = rgb and 0xFF - val wgt = kernel[k + radius] - aAcc += a * wgt - rAcc += r * wgt - gAcc += g * wgt - bAcc += b * wgt - } - val a = aAcc.toInt().coerceIn(0, 255) - val r = rAcc.toInt().coerceIn(0, 255) - val g = gAcc.toInt().coerceIn(0, 255) - val b = bAcc.toInt().coerceIn(0, 255) - dst.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) - } - } - return dst + /** + * 주어진 이미지를 목표 가로/세로 크기로 리사이즈합니다. + * + * - 원본 비율을 무시하고 강제로 맞춥니다. + * - 원본보다 크면 확대, 작으면 축소됩니다. + * + * @param src 원본 BufferedImage + * @param w 목표 가로 크기(px) + * @param h 목표 세로 크기(px) + * @return 리사이즈된 BufferedImage + */ + fun resizeTo(src: BufferedImage, w: Int, h: Int): BufferedImage { + val out = BufferedImage(w, h, src.type.takeIf { it != 0 } ?: BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + // 확대/축소 시 보간법: Bilinear → 부드러운 결과 + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(src, 0, 0, w, h, null) + g.dispose() + return out } - private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray { - val size = 2 * radius + 1 - val kernel = DoubleArray(size) - val sigma2 = 2.0 * sigma * sigma - var sum = 0.0 - for (i in -radius..radius) { - val v = exp(-(i * i) / sigma2) - kernel[i + radius] = v - sum += v - } - // 정규화 - for (i in kernel.indices) kernel[i] /= sum - return kernel + /** + * 가로 크기만 지정하고, 세로는 원본 비율에 맞춰 자동 계산합니다. + * + * @param src 원본 BufferedImage + * @param targetWidth 목표 가로 크기(px) + * @return 리사이즈된 BufferedImage (세로는 자동 비율) + */ + fun resizeToWidth(src: BufferedImage, targetWidth: Int): BufferedImage { + val ratio = targetWidth.toDouble() / src.width + val targetHeight = (src.height * ratio).roundToInt() + return resizeTo(src, targetWidth, targetHeight) } - private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) + /** + * 세로 크기만 지정하고, 가로는 원본 비율에 맞춰 자동 계산합니다. + * + * @param src 원본 BufferedImage + * @param targetHeight 목표 세로 크기(px) + * @return 리사이즈된 BufferedImage (가로는 자동 비율) + */ + fun resizeToHeight(src: BufferedImage, targetHeight: Int): BufferedImage { + val ratio = targetHeight.toDouble() / src.height + val targetWidth = (src.width * ratio).roundToInt() + return resizeTo(src, targetWidth, targetHeight) + } + + /** + * 분리형 가우시안 블러(Separable Gaussian Blur). + * + * - 반경(radius)이 커질수록 더 강하게 흐려집니다. + * - 2D 전체 커널 대신 1D 커널을 두 번 적용하여 성능을 개선했습니다. + */ + private fun gaussianBlurSeparable(src: BufferedImage, radius: Int = 22, sigma: Float? = null): BufferedImage { + require(radius >= 1) + val s = sigma ?: (radius / 3f) + val size = radius * 2 + 1 + + // 1D 가우시안 커널 생성 + val kernel1D = FloatArray(size).also { k -> + var sum = 0f + var i = 0 + for (x in -radius..radius) { + val v = gaussian1D(x.toFloat(), s) + k[i++] = v + sum += v + } + for (j in k.indices) k[j] /= sum // 정규화 + } + + // 수평, 수직 두 번 적용 + val kx = Kernel(size, 1, kernel1D) + val ky = Kernel(1, size, kernel1D) + + val opX = ConvolveOp(kx, ConvolveOp.EDGE_ZERO_FILL, null) + val tmp = opX.filter(src, null) + val opY = ConvolveOp(ky, ConvolveOp.EDGE_ZERO_FILL, null) + return opY.filter(tmp, null) + } + + /** + * 강한 블러 처리(익명화 용도). + * + * 절차: + * 1) 원본 이미지를 축소 (긴 변이 longEdgeTarget 픽셀이 되도록) + * 2) 축소된 이미지에 큰 반경의 가우시안 블러 적용 + * 3) 다시 원본 해상도로 확대 (픽셀 정보가 손실되어 복구 불가능) + * + * @param src 원본 이미지 + * @param longEdgeTarget 축소 후 긴 변의 픽셀 수 (16~64 권장, 작을수록 강하게 흐려짐) + * @param blurRadius 가우시안 블러 반경 (20~32 권장, 클수록 강함) + * @return 원본 해상도의 블러 처리된 이미지 + */ + fun anonymizeStrong(src: BufferedImage, longEdgeTarget: Int = 32, blurRadius: Int = 22): BufferedImage { + val longEdge = max(src.width, src.height) + val scale = longEdgeTarget.toDouble() / longEdge + val smallW = max(1, (src.width * scale).toInt()) + val smallH = max(1, (src.height * scale).toInt()) + + // 1) 축소 + val small = resizeTo(src, smallW, smallH) + + // 2) 강한 블러 + val blurredSmall = gaussianBlurSeparable(small, radius = blurRadius) + + // 3) 다시 원본 해상도로 확대 + val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(blurredSmall, 0, 0, src.width, src.height, null) + g.dispose() + return out + } + + /** + * 1차원 가우시안 함수 값 계산 + */ + private fun gaussian1D(x: Float, sigma: Float): Float { + val s2 = 2 * sigma * sigma + return (1.0 / kotlin.math.sqrt((Math.PI * s2).toFloat())).toFloat() * exp(-(x * x) / s2) + } } From 99386c6d535718019f164d7511bd8b09c26f2ff1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 20:14:06 +0900 Subject: [PATCH 081/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 처리 방식 변경 --- .../image/AdminCharacterImageController.kt | 2 +- .../vividnext/sodalive/utils/ImageBlurUtil.kt | 238 ++++++++++-------- 2 files changed, 137 insertions(+), 103 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index fc16e25..7a8e189 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -142,7 +142,7 @@ class AdminCharacterImageController( val bytes = image.bytes val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") - val blurred = ImageBlurUtil.anonymizeStrong(bimg) + val blurred = ImageBlurUtil.blurFast(bimg) // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 val baos = java.io.ByteArrayOutputStream() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 09e2fe4..830d171 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -1,11 +1,11 @@ package kr.co.vividnext.sodalive.utils -import java.awt.RenderingHints import java.awt.image.BufferedImage -import java.awt.image.ConvolveOp -import java.awt.image.Kernel +import java.awt.image.DataBufferInt +import java.util.stream.IntStream import kotlin.math.exp import kotlin.math.max +import kotlin.math.min import kotlin.math.roundToInt /** @@ -14,126 +14,160 @@ import kotlin.math.roundToInt * - 시그마는 관례적으로 radius/3.0 적용 * - 수평/수직 분리 합성곱으로 품질과 성능 확보 */ +/** + * 고속 가우시안 블러 유틸 + * + * - 원본 비율/해상도 그대로 두고 "큰 반경 블러"만 빠르게 적용하고 싶을 때 사용합니다. + * - 강한 익명화를 원하면(식별 불가 수준) 이 함수 대신 + * "다운스케일 → 큰 반경 블러 → 원본 해상도로 업스케일"을 조합하세요. + * (예: ImageUtils.anonymizeStrongFast 처럼) + */ object ImageBlurUtil { /** - * 주어진 이미지를 목표 가로/세로 크기로 리사이즈합니다. + * 분리형(1D) 가우시안 블러(수평 → 수직 2패스), 배열 접근 기반 고속 구현. * - * - 원본 비율을 무시하고 강제로 맞춥니다. - * - 원본보다 크면 확대, 작으면 축소됩니다. - * - * @param src 원본 BufferedImage - * @param w 목표 가로 크기(px) - * @param h 목표 세로 크기(px) - * @return 리사이즈된 BufferedImage + * @param src 원본 이미지 + * @param radius 가우시안 반경(>=1). 클수록 강하게 흐려짐. (권장 5~64) + * @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효. + * @return 블러된 새 이미지 (TYPE_INT_ARGB) */ - fun resizeTo(src: BufferedImage, w: Int, h: Int): BufferedImage { - val out = BufferedImage(w, h, src.type.takeIf { it != 0 } ?: BufferedImage.TYPE_INT_ARGB) - val g = out.createGraphics() - // 확대/축소 시 보간법: Bilinear → 부드러운 결과 - g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) - g.drawImage(src, 0, 0, w, h, null) - g.dispose() - return out - } + fun blurFast(src: BufferedImage, radius: Int = 100, parallel: Boolean = true): BufferedImage { + require(radius > 0) { "radius must be > 0" } - /** - * 가로 크기만 지정하고, 세로는 원본 비율에 맞춰 자동 계산합니다. - * - * @param src 원본 BufferedImage - * @param targetWidth 목표 가로 크기(px) - * @return 리사이즈된 BufferedImage (세로는 자동 비율) - */ - fun resizeToWidth(src: BufferedImage, targetWidth: Int): BufferedImage { - val ratio = targetWidth.toDouble() / src.width - val targetHeight = (src.height * ratio).roundToInt() - return resizeTo(src, targetWidth, targetHeight) - } + // 1) 프리멀티 알파로 변환 (경계 품질↑) + val s = toPremultiplied(src) // TYPE_INT_ARGB_PRE + val w = s.width + val h = s.height - /** - * 세로 크기만 지정하고, 가로는 원본 비율에 맞춰 자동 계산합니다. - * - * @param src 원본 BufferedImage - * @param targetHeight 목표 세로 크기(px) - * @return 리사이즈된 BufferedImage (가로는 자동 비율) - */ - fun resizeToHeight(src: BufferedImage, targetHeight: Int): BufferedImage { - val ratio = targetHeight.toDouble() / src.height - val targetWidth = (src.width * ratio).roundToInt() - return resizeTo(src, targetWidth, targetHeight) - } + // 2) 중간/최종 버퍼(프리멀티 유지) + val tmp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE) + val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE) - /** - * 분리형 가우시안 블러(Separable Gaussian Blur). - * - * - 반경(radius)이 커질수록 더 강하게 흐려집니다. - * - 2D 전체 커널 대신 1D 커널을 두 번 적용하여 성능을 개선했습니다. - */ - private fun gaussianBlurSeparable(src: BufferedImage, radius: Int = 22, sigma: Float? = null): BufferedImage { - require(radius >= 1) - val s = sigma ?: (radius / 3f) - val size = radius * 2 + 1 + val srcArr = (s.raster.dataBuffer as DataBufferInt).data + val tmpArr = (tmp.raster.dataBuffer as DataBufferInt).data + val dstArr = (dst.raster.dataBuffer as DataBufferInt).data - // 1D 가우시안 커널 생성 - val kernel1D = FloatArray(size).also { k -> - var sum = 0f - var i = 0 - for (x in -radius..radius) { - val v = gaussian1D(x.toFloat(), s) - k[i++] = v - sum += v + // 3) 1D 가우시안 커널(정규화) + // sigma는 일반적으로 radius/3.0이 자연스러운 값 + val sigma = radius / 3.0 + val kernel = buildGaussian1D(radius, sigma) + + // 4) 수평 패스 (y 라인별) + if (parallel) { + IntStream.range(0, h).parallel().forEach { y -> + convolveRow(srcArr, tmpArr, w, h, y, kernel, radius) } - for (j in k.indices) k[j] /= sum // 정규화 + } else { + for (y in 0 until h) convolveRow(srcArr, tmpArr, w, h, y, kernel, radius) } - // 수평, 수직 두 번 적용 - val kx = Kernel(size, 1, kernel1D) - val ky = Kernel(1, size, kernel1D) + // 5) 수직 패스 (x 컬럼별) + if (parallel) { + IntStream.range(0, w).parallel().forEach { x -> + convolveCol(tmpArr, dstArr, w, h, x, kernel, radius) + } + } else { + for (x in 0 until w) convolveCol(tmpArr, dstArr, w, h, x, kernel, radius) + } - val opX = ConvolveOp(kx, ConvolveOp.EDGE_ZERO_FILL, null) - val tmp = opX.filter(src, null) - val opY = ConvolveOp(ky, ConvolveOp.EDGE_ZERO_FILL, null) - return opY.filter(tmp, null) + // 6) 비프리멀티(일반 ARGB)로 변환해서 반환 (파일 저장/그리기 호환성↑) + return toNonPremultiplied(dst) } - /** - * 강한 블러 처리(익명화 용도). - * - * 절차: - * 1) 원본 이미지를 축소 (긴 변이 longEdgeTarget 픽셀이 되도록) - * 2) 축소된 이미지에 큰 반경의 가우시안 블러 적용 - * 3) 다시 원본 해상도로 확대 (픽셀 정보가 손실되어 복구 불가능) - * - * @param src 원본 이미지 - * @param longEdgeTarget 축소 후 긴 변의 픽셀 수 (16~64 권장, 작을수록 강하게 흐려짐) - * @param blurRadius 가우시안 블러 반경 (20~32 권장, 클수록 강함) - * @return 원본 해상도의 블러 처리된 이미지 - */ - fun anonymizeStrong(src: BufferedImage, longEdgeTarget: Int = 32, blurRadius: Int = 22): BufferedImage { - val longEdge = max(src.width, src.height) - val scale = longEdgeTarget.toDouble() / longEdge - val smallW = max(1, (src.width * scale).toInt()) - val smallH = max(1, (src.height * scale).toInt()) + // ───────────────────────────────────────────────────────────────────────────── + // 내부 구현 + // ───────────────────────────────────────────────────────────────────────────── - // 1) 축소 - val small = resizeTo(src, smallW, smallH) + // 수평 합성곱: 경계는 replicate(클램프) + private fun convolveRow(src: IntArray, dst: IntArray, w: Int, h: Int, y: Int, k: DoubleArray, r: Int) { + val base = y * w + for (x in 0 until w) { + var aAcc = 0.0 + var rAcc = 0.0 + var gAcc = 0.0 + var bAcc = 0.0 + var i = -r + while (i <= r) { + val xx = clamp(x + i, 0, w - 1) + val argb = src[base + xx] + val a = (argb ushr 24) and 0xFF + val rr = (argb ushr 16) and 0xFF + val gg = (argb ushr 8) and 0xFF + val bb = argb and 0xFF + val wgt = k[i + r] + aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt + i++ + } + val a = aAcc.roundToInt().coerceIn(0, 255) + val rr = rAcc.roundToInt().coerceIn(0, 255) + val gg = gAcc.roundToInt().coerceIn(0, 255) + val bb = bAcc.roundToInt().coerceIn(0, 255) + dst[base + x] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb + } + } - // 2) 강한 블러 - val blurredSmall = gaussianBlurSeparable(small, radius = blurRadius) + // 수직 합성곱: 경계 replicate(클램프) + private fun convolveCol(src: IntArray, dst: IntArray, w: Int, h: Int, x: Int, k: DoubleArray, r: Int) { + var idx = x + for (y in 0 until h) { + var aAcc = 0.0 + var rAcc = 0.0 + var gAcc = 0.0 + var bAcc = 0.0 + var i = -r + while (i <= r) { + val yy = clamp(y + i, 0, h - 1) + val argb = src[yy * w + x] + val a = (argb ushr 24) and 0xFF + val rr = (argb ushr 16) and 0xFF + val gg = (argb ushr 8) and 0xFF + val bb = argb and 0xFF + val wgt = k[i + r] + aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt + i++ + } + val a = aAcc.roundToInt().coerceIn(0, 255) + val rr = rAcc.roundToInt().coerceIn(0, 255) + val gg = gAcc.roundToInt().coerceIn(0, 255) + val bb = bAcc.roundToInt().coerceIn(0, 255) + dst[idx] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb + idx += w + } + } - // 3) 다시 원본 해상도로 확대 - val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) + // 1D 가우시안 커널 (정규화) + private fun buildGaussian1D(radius: Int, sigma: Double): DoubleArray { + val size = radius * 2 + 1 + val kernel = DoubleArray(size) + val sigma2 = 2.0 * sigma * sigma + var sum = 0.0 + for (i in -radius..radius) { + val v = exp(-(i * i) / sigma2) + kernel[i + radius] = v + sum += v + } + for (i in 0 until size) kernel[i] /= sum + return kernel + } + + private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) + + // 프리멀티/비프리멀티 변환(빠른 방법: Graphics로 그리기) + private fun toPremultiplied(src: BufferedImage): BufferedImage { + if (src.type == BufferedImage.TYPE_INT_ARGB_PRE) return src + val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB_PRE) val g = out.createGraphics() - g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) - g.drawImage(blurredSmall, 0, 0, src.width, src.height, null) + g.drawImage(src, 0, 0, null) g.dispose() return out } - /** - * 1차원 가우시안 함수 값 계산 - */ - private fun gaussian1D(x: Float, sigma: Float): Float { - val s2 = 2 * sigma * sigma - return (1.0 / kotlin.math.sqrt((Math.PI * s2).toFloat())).toFloat() * exp(-(x * x) / s2) + private fun toNonPremultiplied(src: BufferedImage): BufferedImage { + if (src.type == BufferedImage.TYPE_INT_ARGB) return src + val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + g.drawImage(src, 0, 0, null) + g.dispose() + return out } } From 539b9fb2b20ca6052342b58ae9cc8c35b93f4532 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 20:52:39 +0900 Subject: [PATCH 082/119] =?UTF-8?q?fix:=20=EC=9C=A0=EC=A0=80/=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 불필요한 Response 제거 --- .../admin/chat/character/image/dto/CharacterImageDtos.kt | 6 ------ .../chat/character/image/CharacterImageController.kt | 1 - .../chat/character/image/dto/CharacterImageListDtos.kt | 1 - 3 files changed, 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt index 21ec681..29f6bfd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt @@ -29,9 +29,6 @@ data class AdminCharacterImageResponse( val characterId: Long, val imagePriceCan: Long, val messagePriceCan: Long, - val isAdult: Boolean, - val sortOrder: Int, - val active: Boolean, val imageUrl: String, val triggers: List ) { @@ -46,9 +43,6 @@ data class AdminCharacterImageResponse( characterId = entity.chatCharacter.id!!, imagePriceCan = entity.imagePriceCan, messagePriceCan = entity.messagePriceCan, - isAdult = entity.isAdult, - sortOrder = entity.sortOrder, - active = entity.isActive, imageUrl = url, triggers = entity.triggerMappings.map { it.tag.word } ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index 7f59c75..c2866fe 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -55,7 +55,6 @@ class CharacterImageController( imageUrl = url, isOwned = isOwned, imagePriceCan = img.imagePriceCan, - isAdult = img.isAdult, sortOrder = img.sortOrder ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt index ad19968..cfef1a6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImageListDtos.kt @@ -7,7 +7,6 @@ data class CharacterImageListItemResponse( @JsonProperty("imageUrl") val imageUrl: String, @JsonProperty("isOwned") val isOwned: Boolean, @JsonProperty("imagePriceCan") val imagePriceCan: Long, - @JsonProperty("isAdult") val isAdult: Boolean, @JsonProperty("sortOrder") val sortOrder: Int ) From 7355949c1ef68396e5d47340eb82978cbaf85d77 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 20:54:44 +0900 Subject: [PATCH 083/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 radius 100 -> 160 --- src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 830d171..01396c2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -31,7 +31,7 @@ object ImageBlurUtil { * @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효. * @return 블러된 새 이미지 (TYPE_INT_ARGB) */ - fun blurFast(src: BufferedImage, radius: Int = 100, parallel: Boolean = true): BufferedImage { + fun blurFast(src: BufferedImage, radius: Int = 160, parallel: Boolean = true): BufferedImage { require(radius > 0) { "radius must be > 0" } // 1) 프리멀티 알파로 변환 (경계 품질↑) From 7dd585c3dd528e9c0ae636d5a8de20f31b514902 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 21:10:35 +0900 Subject: [PATCH 084/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 radius 160 -> 200 --- src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 01396c2..39a2411 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -31,7 +31,7 @@ object ImageBlurUtil { * @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효. * @return 블러된 새 이미지 (TYPE_INT_ARGB) */ - fun blurFast(src: BufferedImage, radius: Int = 160, parallel: Boolean = true): BufferedImage { + fun blurFast(src: BufferedImage, radius: Int = 200, parallel: Boolean = true): BufferedImage { require(radius > 0) { "radius must be > 0" } // 1) 프리멀티 알파로 변환 (경계 품질↑) From f8be99547ae4b332287968806edb29baf99de484 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 21:18:29 +0900 Subject: [PATCH 085/119] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 radius 200 -> 240 --- src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 39a2411..aadde67 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -31,7 +31,7 @@ object ImageBlurUtil { * @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효. * @return 블러된 새 이미지 (TYPE_INT_ARGB) */ - fun blurFast(src: BufferedImage, radius: Int = 200, parallel: Boolean = true): BufferedImage { + fun blurFast(src: BufferedImage, radius: Int = 240, parallel: Boolean = true): BufferedImage { require(radius > 0) { "radius must be > 0" } // 1) 프리멀티 알파로 변환 (경계 품질↑) From 2ac0a5f896e814d5e694688b9a0f0511e13cf5de Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 22 Aug 2025 01:21:04 +0900 Subject: [PATCH 086/119] =?UTF-8?q?feat(character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isAdult 값 추가 --- .../admin/chat/character/image/dto/CharacterImageDtos.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt index 29f6bfd..4c5bb39 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/dto/CharacterImageDtos.kt @@ -30,7 +30,8 @@ data class AdminCharacterImageResponse( val imagePriceCan: Long, val messagePriceCan: Long, val imageUrl: String, - val triggers: List + val triggers: List, + val isAdult: Boolean ) { companion object { fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse { @@ -44,7 +45,8 @@ data class AdminCharacterImageResponse( imagePriceCan = entity.imagePriceCan, messagePriceCan = entity.messagePriceCan, imageUrl = url, - triggers = entity.triggerMappings.map { it.tag.word } + triggers = entity.triggerMappings.map { it.tag.word }, + isAdult = entity.isAdult ) } } From 692e060f6d16a67b0a3bfa9cd06784d6b1a54c8f Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 22 Aug 2025 21:37:18 +0900 Subject: [PATCH 087/119] =?UTF-8?q?feat(character-image):=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=8B=A8=EB=8F=85=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?API=20=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20=EC=97=B0=EB=8F=99=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 - 구매 요청/응답 DTO 추가 - 미보유 시 캔 차감 및 구매 이력 저장 - 서명 URL(5분) 반환 --- .../sodalive/can/payment/CanPaymentService.kt | 46 +++++++++++++++++++ .../image/CharacterImageController.kt | 37 +++++++++++++++ .../image/dto/CharacterImagePurchaseDtos.kt | 12 +++++ 3 files changed, 95 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 5f60109..092a6e4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanRepository +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.order.Order @@ -327,4 +328,49 @@ class CanPaymentService( chargeRepository.save(charge) } } + + @Transactional + fun spendCanForCharacterImage( + memberId: Long, + needCan: Int, + image: CharacterImage, + container: String + ) { + val member = memberRepository.findByIdOrNull(id = memberId) + ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + + val useRewardCan = spendRewardCan(member, needCan, container) + val useChargeCan = if (needCan - useRewardCan.total > 0) { + spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container) + } else { + null + } + + if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + throw SodaException( + "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + + "캔이 부족합니다. 충전 후 이용해 주세요." + ) + } + + if (!useRewardCan.verify() || useChargeCan?.verify() == false) { + throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + } + + val useCan = UseCan( + canUsage = CanUsage.CHARACTER_IMAGE_PURCHASE, + can = useChargeCan?.total ?: 0, + rewardCan = useRewardCan.total, + isSecret = false + ) + useCan.member = member + useCan.characterImage = image + + useCanRepository.save(useCan) + + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index c2866fe..97337e0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.character.image import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront +import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListItemResponse import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImageListResponse +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseRequest +import kr.co.vividnext.sodalive.chat.character.image.dto.CharacterImagePurchaseResponse import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member @@ -10,6 +13,8 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.data.domain.PageRequest import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +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.RestController @@ -19,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController class CharacterImageController( private val imageService: CharacterImageService, private val imageCloudFront: ImageContentCloudFront, + private val canPaymentService: CanPaymentService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { @@ -67,4 +73,35 @@ class CharacterImageController( ) ) } + + @PostMapping("/purchase") + fun purchase( + @RequestBody req: CharacterImagePurchaseRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val image = imageService.getById(req.imageId) + if (!image.isActive) throw SodaException("비활성화된 이미지입니다.") + + val isOwned = (image.imagePriceCan == 0L) || + imageService.isOwnedImageByMember(image.id!!, member.id!!) + + if (!isOwned) { + val needCan = image.imagePriceCan.toInt() + if (needCan <= 0) throw SodaException("구매 가격이 잘못되었습니다.") + + canPaymentService.spendCanForCharacterImage( + memberId = member.id!!, + needCan = needCan, + image = image, + container = req.container + ) + } + + val expiration = 5L * 60L * 1000L // 5분 + val signedUrl = imageCloudFront.generateSignedURL(image.imagePath, expiration) + ApiResponse.ok(CharacterImagePurchaseResponse(imageUrl = signedUrl)) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt new file mode 100644 index 0000000..83cedf2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/dto/CharacterImagePurchaseDtos.kt @@ -0,0 +1,12 @@ +package kr.co.vividnext.sodalive.chat.character.image.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class CharacterImagePurchaseRequest( + @JsonProperty("imageId") val imageId: Long, + @JsonProperty("container") val container: String +) + +data class CharacterImagePurchaseResponse( + @JsonProperty("imageUrl") val imageUrl: String +) From b3e7c00232de0341c005896fc4e5144dc6cf5bdb Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 23 Aug 2025 05:34:02 +0900 Subject: [PATCH 088/119] =?UTF-8?q?feat(chat):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80/=EC=9C=A0=EB=A3=8C(PPV)=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=20=E2=80=94=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=C2=B7=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B7DTO=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=20=EB=B0=8F=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=A0=84=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatMessageType(TEXT/IMAGE) 도입 - ChatMessage에 messageType/characterImage/imagePath/price 추가 - ChatMessageItemDto에 messageType/imageUrl/price/hasAccess 추가 - 캐릭터 답변 로직 - 텍스트 메시지 항상 저장/전송 - 트리거 일치 시 이미지 메시지 추가 저장/전송 - 미보유 시 blur + price 스냅샷, 보유 시 원본 + price=null - enterChatRoom/getChatMessages 응답에 확장된 필드 매핑 및 hasAccess 계산 반영 --- .../sodalive/chat/room/ChatMessage.kt | 24 ++- .../sodalive/chat/room/ChatMessageType.kt | 13 ++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 6 +- .../chat/room/service/ChatRoomService.kt | 144 +++++++++++++++--- 4 files changed, 163 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt index 1013a3e..fb1c35e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessage.kt @@ -1,14 +1,18 @@ package kr.co.vividnext.sodalive.chat.room +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.common.BaseEntity import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated import javax.persistence.FetchType import javax.persistence.JoinColumn import javax.persistence.ManyToOne @Entity class ChatMessage( + // 텍스트 메시지 본문. 현재는 NOT NULL 유지. IMAGE 타입 등 비텍스트 메시지는 빈 문자열("") 저장 방침. @Column(columnDefinition = "TEXT", nullable = false) val message: String, @@ -20,5 +24,23 @@ class ChatMessage( @JoinColumn(name = "participant_id", nullable = false) val participant: ChatParticipant, - val isActive: Boolean = true + val isActive: Boolean = true, + + @Enumerated(EnumType.STRING) + @Column(name = "message_type", nullable = false) + val messageType: ChatMessageType = ChatMessageType.TEXT, + + // 미리 저장된 캐릭터 이미지 참조 (옵션) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_image_id", nullable = true) + val characterImage: CharacterImage? = null, + + // 이미지 정적 경로 스냅샷 (옵션) + @Column(name = "image_path", nullable = true, length = 1024) + val imagePath: String? = null, + + // 메시지 가격 (옵션). 제공되는 경우 1 이상이어야 함. + // Bean Validation 사용 시 @field:Min(1) 추가 고려. + @Column(name = "price", nullable = true) + val price: Int? = null ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt new file mode 100644 index 0000000..0a1756a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatMessageType.kt @@ -0,0 +1,13 @@ +package kr.co.vividnext.sodalive.chat.room + +/** + * 채팅 메시지 타입 + * - TEXT: 일반 텍스트 메시지 + * - IMAGE: 이미지 메시지(캐릭터 이미지 등) + * + * 유의: 유료 여부는 별도 price 필드로 표현합니다. + */ +enum class ChatMessageType { + TEXT, + IMAGE +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index e7691b6..267e67f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -39,7 +39,11 @@ data class ChatMessageItemDto( val message: String, val profileImageUrl: String, val mine: Boolean, - val createdAt: Long + val createdAt: Long, + val messageType: String, + val imageUrl: String?, + val price: Int?, + val hasAccess: Boolean ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 04c9582..2521107 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -1,8 +1,11 @@ package kr.co.vividnext.sodalive.chat.room.service import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.chat.character.image.CharacterImage +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService import kr.co.vividnext.sodalive.chat.room.ChatMessage +import kr.co.vividnext.sodalive.chat.room.ChatMessageType import kr.co.vividnext.sodalive.chat.room.ChatParticipant import kr.co.vividnext.sodalive.chat.room.ChatRoom import kr.co.vividnext.sodalive.chat.room.ParticipantType @@ -34,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional import org.springframework.web.client.RestTemplate import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneId import java.util.UUID @Service @@ -42,6 +46,7 @@ class ChatRoomService( private val participantRepository: ChatParticipantRepository, private val messageRepository: ChatMessageRepository, private val characterService: ChatCharacterService, + private val characterImageService: CharacterImageService, @Value("\${weraser.api-key}") private val apiKey: String, @@ -293,14 +298,31 @@ class ChatRoomService( ParticipantType.CHARACTER -> sender.character?.imagePath } val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, profileImageUrl = senderImageUrl, mine = sender.member?.id == member.id, - createdAt = createdAtMillis + createdAt = createdAtMillis, + messageType = msg.messageType.name, + imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + price = msg.price, + hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { + if (msg.price == null) { + true + } else { + msg.characterImage?.id?.let { + characterImageService.isOwnedImageByMember( + it, + member.id!! + ) + } ?: true + } + } else { + true + } ) } @@ -455,15 +477,32 @@ class ChatRoomService( ParticipantType.USER -> sender.member?.profileImage ParticipantType.CHARACTER -> sender.character?.imagePath } - val imageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L ChatMessageItemDto( messageId = msg.id!!, message = msg.message, - profileImageUrl = imageUrl, + profileImageUrl = senderImageUrl, mine = sender.member?.id == member.id, - createdAt = createdAtMillis + createdAt = createdAtMillis, + messageType = msg.messageType.name, + imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + price = msg.price, + hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { + if (msg.price == null) { + true + } else { + msg.characterImage?.id?.let { + characterImageService.isOwnedImageByMember( + it, + member.id!! + ) + } ?: true + } + } else { + true + } ) } @@ -509,30 +548,77 @@ class ChatRoomService( ) messageRepository.save(myMsgEntity) - // 7) 캐릭터 메시지 저장 - val characterMsgEntity = ChatMessage( - message = characterReply, - chatRoom = room, - participant = characterParticipant, - isActive = true + // 7) 캐릭터 텍스트 메시지 항상 저장 + val characterTextMsg = messageRepository.save( + ChatMessage( + message = characterReply, + chatRoom = room, + participant = characterParticipant, + isActive = true + ) ) - val savedCharacterMsg = messageRepository.save(characterMsgEntity) - // 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소) + // 응답 프로필 이미지 URL 공통 구성 val profilePath = characterParticipant.character?.imagePath val defaultPath = profilePath ?: "profile/default-profile.png" - val imageUrl = "$imageHost/$defaultPath" - val dto = ChatMessageItemDto( - messageId = savedCharacterMsg.id!!, - message = savedCharacterMsg.message, - profileImageUrl = imageUrl, + val senderImageUrl = "$imageHost/$defaultPath" + + val textDto = ChatMessageItemDto( + messageId = characterTextMsg.id!!, + message = characterTextMsg.message, + profileImageUrl = senderImageUrl, mine = false, - createdAt = savedCharacterMsg.createdAt?.atZone(java.time.ZoneId.systemDefault())?.toInstant() + createdAt = characterTextMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() ?.toEpochMilli() - ?: 0L + ?: 0L, + messageType = ChatMessageType.TEXT.name, + imageUrl = null, + price = null, + hasAccess = true ) - return listOf(dto) + // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) + val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) + if (matchedImage != null) { + val owned = characterImageService.isOwnedImageByMember(matchedImage.id!!, member.id!!) + val priceInt: Int? = if (owned) { + null + } else { + val p = matchedImage.messagePriceCan + if (p <= 0L) null else if (p > Int.MAX_VALUE) Int.MAX_VALUE else p.toInt() + } + // 보유하지 않은 경우 블러 이미지로 전송 + val snapshotPath = if (owned) matchedImage.imagePath else matchedImage.blurImagePath + val imageMsg = messageRepository.save( + ChatMessage( + message = "", + chatRoom = room, + participant = characterParticipant, + isActive = true, + messageType = ChatMessageType.IMAGE, + characterImage = matchedImage, + imagePath = snapshotPath, + price = priceInt + ) + ) + + val imageDto = ChatMessageItemDto( + messageId = imageMsg.id!!, + message = imageMsg.message, + profileImageUrl = senderImageUrl, + mine = false, + createdAt = imageMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() + ?.toEpochMilli() + ?: 0L, + messageType = ChatMessageType.IMAGE.name, + imageUrl = imageMsg.imagePath?.let { "$imageHost/$it" }, + price = imageMsg.price, + hasAccess = owned || imageMsg.price == null + ) + return listOf(textDto, imageDto) + } + + return listOf(textDto) } private fun callExternalApiForChatSendWithRetry( @@ -603,4 +689,18 @@ class ChatRoomService( } return characterContent } + + private fun findTriggeredCharacterImage(characterId: Long, replyText: String): CharacterImage? { + val text = replyText.lowercase() + val images: List = characterImageService.listActiveByCharacter(characterId) + for (img in images) { + val triggers = img.triggerMappings + .map { it.tag.word.trim().lowercase() } + .filter { it.isNotBlank() } + if (triggers.isEmpty()) continue + val allIncluded = triggers.all { t -> text.contains(t) } + if (allIncluded) return img + } + return null + } } From 12574dbe462bf12c4895ea2c8ad81dbc0d96cfec Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 25 Aug 2025 14:01:10 +0900 Subject: [PATCH 089/119] =?UTF-8?q?feat(chat-room,=20payment):=20=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=97=B0=EB=8F=99(=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B4=EC=9C=A0=20=EC=B2=98=EB=A6=AC=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 채팅 유료 메시지 구매 API 추가: POST /api/chat/room/{chatRoomId}/messages/{messageId}/purchase - ChatRoomService.purchaseMessage 구현: 참여/유효성/가격 검증, 이미지 메시지 보유 시 결제 생략, 결제 완료 시 ChatMessageItemDto 반환 - CanPaymentService.spendCanForChatMessage 추가: UseCan에 chatMessage(+이미지 메시지면 characterImage) 연동 저장 및 게이트웨이 별 정산 기록(setUseCanCalculate) - Character Image 결제 경로에 정산 기록 호출 누락분 보강 - ChatMessageItemDto 변환 헬퍼(toChatMessageItemDto) 추가 및 접근권한(hasAccess) 계산 일원화 --- .../sodalive/can/payment/CanPaymentService.kt | 49 ++++++++++++ .../room/controller/ChatRoomController.kt | 21 +++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 7 ++ .../chat/room/service/ChatRoomService.kt | 79 +++++++++++++++++++ 4 files changed, 156 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 092a6e4..275a56d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -373,4 +373,53 @@ class CanPaymentService( setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) } + + @Transactional + fun spendCanForChatMessage( + memberId: Long, + needCan: Int, + message: kr.co.vividnext.sodalive.chat.room.ChatMessage, + container: String + ) { + val member = memberRepository.findByIdOrNull(id = memberId) + ?: throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + + val useRewardCan = spendRewardCan(member, needCan, container) + val useChargeCan = if (needCan - useRewardCan.total > 0) { + spendChargeCan(member, needCan = needCan - useRewardCan.total, container = container) + } else { + null + } + + if (needCan - useRewardCan.total - (useChargeCan?.total ?: 0) > 0) { + throw SodaException( + "${needCan - useRewardCan.total - (useChargeCan?.total ?: 0)} " + + "캔이 부족합니다. 충전 후 이용해 주세요." + ) + } + + if (!useRewardCan.verify() || useChargeCan?.verify() == false) { + throw SodaException("잘못된 요청입니다.\n다시 시도해 주세요.") + } + + val useCan = UseCan( + canUsage = CanUsage.CHAT_MESSAGE_PURCHASE, + can = useChargeCan?.total ?: 0, + rewardCan = useRewardCan.total, + isSecret = false + ) + useCan.member = member + useCan.chatMessage = message + // 이미지 메시지의 경우 이미지 연관도 함께 기록 + message.characterImage?.let { img -> + useCan.characterImage = img + } + + useCanRepository.save(useCan) + + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.PG) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.POINT_CLICK_AD) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.GOOGLE_IAP) + setUseCanCalculate(null, useRewardCan, useChargeCan, useCan, paymentGateway = PaymentGateway.APPLE_IAP) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index 0a6f2a0..f5bd481 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.chat.room.controller +import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService @@ -159,4 +160,24 @@ class ChatRoomController( ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message)) } } + + /** + * 유료 메시지 구매 API + * - 참여 여부 검증 + * - 이미지 메시지의 경우 이미 보유 시 결제 없이 true 반환 + * - 그 외 가격 검증 후 CanPaymentService 통해 결제 처리 + */ + @PostMapping("/{chatRoomId}/messages/{messageId}/purchase") + fun purchaseMessage( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @PathVariable messageId: Long, + @RequestBody request: ChatMessagePurchaseRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) + ApiResponse.ok(result) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 267e67f..8cf1ed4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -130,6 +130,13 @@ data class SendChatMessageRequest( val message: String ) +/** + * 유료 메시지 구매 요청 DTO + */ +data class ChatMessagePurchaseRequest( + val container: String +) + /** * 외부 API 채팅 전송 응답 DTO */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 2521107..55d58bb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -47,6 +47,7 @@ class ChatRoomService( private val messageRepository: ChatMessageRepository, private val characterService: ChatCharacterService, private val characterImageService: CharacterImageService, + private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, @Value("\${weraser.api-key}") private val apiKey: String, @@ -62,6 +63,46 @@ class ChatRoomService( ) { private val log = LoggerFactory.getLogger(ChatRoomService::class.java) + @Transactional + fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto { + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + // 참여 여부 검증 + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + + val message = messageRepository.findById(messageId).orElseThrow { + SodaException("메시지를 찾을 수 없습니다.") + } + if (!message.isActive) throw SodaException("비활성화된 메시지입니다.") + if (message.chatRoom.id != room.id) throw SodaException("잘못된 접근입니다") + + val price = message.price ?: throw SodaException("구매할 수 없는 메시지입니다.") + if (price <= 0) throw SodaException("구매 가격이 잘못되었습니다.") + + // 이미지 메시지인 경우: 이미 소유했다면 결제 생략하고 DTO 반환 + if (message.messageType == ChatMessageType.IMAGE) { + val image = message.characterImage + if (image != null) { + val alreadyOwned = characterImageService.isOwnedImageByMember(image.id!!, member.id!!) + if (alreadyOwned) { + return toChatMessageItemDto(message, member) + } + } + } + + // 결제 진행 및 UseCan 기록 (이미지 메시지면 chatMessage + characterImage 동시 기록됨) + canPaymentService.spendCanForChatMessage( + memberId = member.id!!, + needCan = price, + message = message, + container = container + ) + // 결제 완료 후 접근 가능 상태로 DTO 반환 + return toChatMessageItemDto(message, member, forceHasAccess = true) + } + /** * 채팅방 생성 또는 조회 * @@ -621,6 +662,44 @@ class ChatRoomService( return listOf(textDto) } + private fun toChatMessageItemDto( + msg: ChatMessage, + member: Member, + forceHasAccess: Boolean = false + ): ChatMessageItemDto { + val sender = msg.participant + val profilePath = when (sender.participantType) { + ParticipantType.USER -> sender.member?.profileImage + ParticipantType.CHARACTER -> sender.character?.imagePath + } + val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" + val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() ?: 0L + val hasAccess = if (forceHasAccess) { + true + } else if (msg.messageType == ChatMessageType.IMAGE) { + if (msg.price == null) { + true + } else { + msg.characterImage?.id?.let { + characterImageService.isOwnedImageByMember(it, member.id!!) + } ?: true + } + } else { + true + } + return ChatMessageItemDto( + messageId = msg.id!!, + message = msg.message, + profileImageUrl = senderImageUrl, + mine = sender.member?.id == member.id, + createdAt = createdAtMillis, + messageType = msg.messageType.name, + imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + price = msg.price, + hasAccess = hasAccess + ) + } + private fun callExternalApiForChatSendWithRetry( userId: String, characterUUID: String, From 5a58fe90777d25861bf0c17134aa6a348c99da9a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 25 Aug 2025 14:28:11 +0900 Subject: [PATCH 090/119] =?UTF-8?q?feat(chat):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=20CloudFront=20=EC=84=9C=EB=AA=85=20URL=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20DTO=20=EB=B3=80=ED=99=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조회 가능한(보유/무료/결제완료) 이미지 메시지의 이미지 URL을 ImageContentCloudFront.generateSignedURL(만료 5분)로 생성 - 접근 불가(미보유, 유료 미구매) 이미지 메시지는 기존 공개 호스트 URL(블러/스냅샷 경로) 유지 - ChatRoomService에 ImageContentCloudFront를 주입하고, toChatMessageItemDto에서 이미지 URL/hasAccess 결정 로직 단일화 - enterChatRoom, getChatMessages, sendMessage 경로의 중복된 DTO 매핑 로직 제거 - purchaseMessage 결제 완료 시 forceHasAccess=true로 접근 가능 DTO 반환 --- .../chat/room/service/ChatRoomService.kt | 104 ++++-------------- 1 file changed, 22 insertions(+), 82 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 55d58bb..35b2ed8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -48,6 +48,7 @@ class ChatRoomService( private val characterService: ChatCharacterService, private val characterImageService: CharacterImageService, private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, + private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, @Value("\${weraser.api-key}") private val apiKey: String, @@ -332,40 +333,7 @@ class ChatRoomService( } val messagesAsc = fetched.sortedBy { it.createdAt } - val items = messagesAsc.map { msg -> - val sender = msg.participant - val profilePath = when (sender.participantType) { - ParticipantType.USER -> sender.member?.profileImage - ParticipantType.CHARACTER -> sender.character?.imagePath - } - val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() - ?: 0L - ChatMessageItemDto( - messageId = msg.id!!, - message = msg.message, - profileImageUrl = senderImageUrl, - mine = sender.member?.id == member.id, - createdAt = createdAtMillis, - messageType = msg.messageType.name, - imageUrl = msg.imagePath?.let { "$imageHost/$it" }, - price = msg.price, - hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { - if (msg.price == null) { - true - } else { - msg.characterImage?.id?.let { - characterImageService.isOwnedImageByMember( - it, - member.id!! - ) - } ?: true - } - } else { - true - } - ) - } + val items = messagesAsc.map { toChatMessageItemDto(it, member) } return ChatRoomEnterResponse( roomId = room.id!!, @@ -512,40 +480,7 @@ class ChatRoomService( // createdAt 오름차순으로 정렬하여 반환 val messagesAsc = fetched.sortedBy { it.createdAt } - val items = messagesAsc.map { msg -> - val sender = msg.participant - val profilePath = when (sender.participantType) { - ParticipantType.USER -> sender.member?.profileImage - ParticipantType.CHARACTER -> sender.character?.imagePath - } - val senderImageUrl = "$imageHost/${profilePath ?: "profile/default-profile.png"}" - val createdAtMillis = msg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() - ?: 0L - ChatMessageItemDto( - messageId = msg.id!!, - message = msg.message, - profileImageUrl = senderImageUrl, - mine = sender.member?.id == member.id, - createdAt = createdAtMillis, - messageType = msg.messageType.name, - imageUrl = msg.imagePath?.let { "$imageHost/$it" }, - price = msg.price, - hasAccess = if (msg.messageType == ChatMessageType.IMAGE) { - if (msg.price == null) { - true - } else { - msg.characterImage?.id?.let { - characterImageService.isOwnedImageByMember( - it, - member.id!! - ) - } ?: true - } - } else { - true - } - ) - } + val items = messagesAsc.map { toChatMessageItemDto(it, member) } return ChatMessagesPageResponse( messages = items, @@ -643,19 +578,7 @@ class ChatRoomService( ) ) - val imageDto = ChatMessageItemDto( - messageId = imageMsg.id!!, - message = imageMsg.message, - profileImageUrl = senderImageUrl, - mine = false, - createdAt = imageMsg.createdAt?.atZone(ZoneId.systemDefault())?.toInstant() - ?.toEpochMilli() - ?: 0L, - messageType = ChatMessageType.IMAGE.name, - imageUrl = imageMsg.imagePath?.let { "$imageHost/$it" }, - price = imageMsg.price, - hasAccess = owned || imageMsg.price == null - ) + val imageDto = toChatMessageItemDto(imageMsg, member) return listOf(textDto, imageDto) } @@ -687,6 +610,23 @@ class ChatRoomService( } else { true } + val expirationMs = 5L * 60L * 1000L + val resolvedImageUrl: String? = if (msg.messageType == ChatMessageType.IMAGE) { + val path = if (hasAccess) { + msg.characterImage?.imagePath ?: msg.imagePath + } else { + msg.imagePath + } + path?.let { p -> + if (hasAccess) { + imageCloudFront.generateSignedURL(p, expirationMs) + } else { + "$imageHost/$p" + } + } + } else { + null + } return ChatMessageItemDto( messageId = msg.id!!, message = msg.message, @@ -694,7 +634,7 @@ class ChatRoomService( mine = sender.member?.id == member.id, createdAt = createdAtMillis, messageType = msg.messageType.name, - imageUrl = msg.imagePath?.let { "$imageHost/$it" }, + imageUrl = resolvedImageUrl, price = msg.price, hasAccess = hasAccess ) From 8b1dd7cb95ed1452fb20ee96061d553f60ca947a Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 25 Aug 2025 17:37:51 +0900 Subject: [PATCH 091/119] =?UTF-8?q?temp:=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20=EC=BA=90=EB=A6=AD=ED=84=B0=2030=EA=B0=9C?= =?UTF-8?q?=20=EB=B3=B4=EC=97=AC=EC=A3=BC=EB=8A=94=20=EA=B2=83=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/controller/ChatCharacterController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 97912c0..f5d5c69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -75,7 +75,7 @@ class ChatCharacterController( } // 최신 캐릭터 조회 (최대 10개) - val newCharacters = service.getNewCharacters(10) + val newCharacters = service.getNewCharacters(30) .map { Character( characterId = it.id!!, From 6ecac8d331a919f4d4fbe252bcaefa05ba47f7e2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 13:22:49 +0900 Subject: [PATCH 092/119] =?UTF-8?q?feat(quota)!:=20AI=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=BF=BC=ED=84=B0(=EB=AC=B4=EB=A3=8C/=EC=9C=A0=EB=A3=8C)=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=9E=85=EC=9E=A5/=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChatQuota 엔티티/레포/서비스/컨트롤러 추가 - 입장 시 Lazy refill 적용, 전송 시 무료 우선 차감 및 잔여/리필 시간 응답 포함 - ChatRoomEnterResponse에 totalRemaining/nextRechargeAtEpoch 추가 - SendChatMessageResponse 신설 및 send API 응답 스키마 변경 - CanUsage에 CHAT_QUOTA_PURCHASE 추가, CanPaymentService/CanService에 결제 흐름 반영 --- .../co/vividnext/sodalive/can/CanService.kt | 1 + .../sodalive/can/payment/CanPaymentService.kt | 3 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 3 +- .../sodalive/chat/quota/ChatQuota.kt | 21 +++++ .../chat/quota/ChatQuotaController.kt | 65 +++++++++++++++ .../chat/quota/ChatQuotaRepository.kt | 15 ++++ .../sodalive/chat/quota/ChatQuotaService.kt | 80 +++++++++++++++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 13 ++- .../chat/room/service/ChatRoomService.kt | 30 +++++-- 9 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index c77366a..7038575 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -74,6 +74,7 @@ class CanService(private val repository: CanRepository) { CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" + CanUsage.CHAT_QUOTA_PURCHASE -> "AI 채팅 개수 구매" } val createdAt = it.createdAt!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index 275a56d..c9d2498 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -110,6 +110,9 @@ class CanPaymentService( recipientId = liveRoom.member!!.id!! useCan.room = liveRoom useCan.member = member + } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { + // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 + useCan.member = member } else { throw SodaException("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 976845c..4f06828 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -11,5 +11,6 @@ enum class CanUsage { ALARM_SLOT, AUDITION_VOTE, CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) - CHARACTER_IMAGE_PURCHASE // 캐릭터 이미지 단독 구매 + CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 + CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt new file mode 100644 index 0000000..f035202 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuota.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.quota + +import java.time.LocalDateTime +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table +import javax.persistence.Version + +@Entity +@Table(name = "chat_quota") +class ChatQuota( + @Id + val memberId: Long, + var remainingFree: Int = 10, + var remainingPaid: Int = 0, + var nextRechargeAt: LocalDateTime? = null, + @Version + var version: Long? = null +) { + fun total(): Int = remainingFree + remainingPaid +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt new file mode 100644 index 0000000..b7fe447 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -0,0 +1,65 @@ +package kr.co.vividnext.sodalive.chat.quota + +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/quota") +class ChatQuotaController( + private val chatQuotaService: ChatQuotaService, + private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService +) { + + data class ChatQuotaStatusResponse( + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? + ) + + data class ChatQuotaPurchaseRequest( + val container: String, + val addPaid: Int = 50, + val needCan: Int = 30 + ) + + @GetMapping("/me") + fun getMyQuota( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ): ApiResponse = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val s = chatQuotaService.getStatus(member.id!!) + ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) + } + + @PostMapping("/purchase") + fun purchaseQuota( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @RequestBody request: ChatQuotaPurchaseRequest + ): ApiResponse = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (request.container.isBlank()) throw SodaException("container를 확인해주세요.") + + // 30캔 차감 처리 (결제 기록 남김) + canPaymentService.spendCan( + memberId = member.id!!, + needCan = if (request.needCan > 0) request.needCan else 30, + canUsage = CanUsage.CHAT_QUOTA_PURCHASE, + container = request.container + ) + + // 유료 횟수 적립 (기본 50) + val add = if (request.addPaid > 0) request.addPaid else 50 + chatQuotaService.purchase(member.id!!, add) + ApiResponse.ok(true) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt new file mode 100644 index 0000000..ae84b4b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaRepository.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.chat.quota + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType + +interface ChatQuotaRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select q from ChatQuota q where q.memberId = :memberId") + fun findForUpdate(@Param("memberId") memberId: Long): ChatQuota? + + fun findByMemberId(memberId: Long): ChatQuota? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt new file mode 100644 index 0000000..6309957 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -0,0 +1,80 @@ +package kr.co.vividnext.sodalive.chat.quota + +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.ZoneId + +@Service +class ChatQuotaService( + private val repo: ChatQuotaRepository +) { + companion object { + private const val FREE_BUCKET = 10 + private const val RECHARGE_HOURS = 6L + } + + data class QuotaStatus( + val totalRemaining: Int, + val nextRechargeAtEpochMillis: Long? + ) + + @Transactional + fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { + val now = LocalDateTime.now() + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) { + quota.remainingFree = FREE_BUCKET + quota.nextRechargeAt = null + } + val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch) + } + + @Transactional + fun consumeOne(memberId: Long) { + val now = LocalDateTime.now() + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + + when { + quota.remainingFree > 0 -> { + quota.remainingFree -= 1 + if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { + quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + } + } + + quota.remainingPaid > 0 -> { + quota.remainingPaid -= 1 + if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { + quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + } + } + + else -> { + if (quota.nextRechargeAt == null) { + quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + } + throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") + } + } + } + + @Transactional(readOnly = true) + fun getStatus(memberId: Long): QuotaStatus { + val q = repo.findByMemberId(memberId) ?: return QuotaStatus( + totalRemaining = FREE_BUCKET, + nextRechargeAtEpochMillis = null + ) + val total = q.remainingFree + q.remainingPaid + val epoch = q.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + return QuotaStatus(totalRemaining = total, nextRechargeAtEpochMillis = epoch) + } + + @Transactional + fun purchase(memberId: Long, addPaid: Int) { + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + quota.remainingPaid += addPaid + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index 8cf1ed4..b43d8a7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -180,5 +180,16 @@ data class ChatRoomEnterResponse( val roomId: Long, val character: ChatRoomEnterCharacterDto, val messages: List, - val hasMoreMessages: Boolean + val hasMoreMessages: Boolean, + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? +) + +/** + * 채팅 메시지 전송 응답 DTO (메시지 + 쿼터 상태) + */ +data class SendChatMessageResponse( + val messages: List, + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 35b2ed8..6437c41 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse +import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse import kr.co.vividnext.sodalive.chat.room.repository.ChatMessageRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository @@ -49,6 +50,7 @@ class ChatRoomService( private val characterImageService: CharacterImageService, private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, + private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, @Value("\${weraser.api-key}") private val apiKey: String, @@ -335,11 +337,16 @@ class ChatRoomService( val messagesAsc = fetched.sortedBy { it.createdAt } val items = messagesAsc.map { toChatMessageItemDto(it, member) } + // 입장 시 Lazy refill 적용 후 상태 반환 + val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) + return ChatRoomEnterResponse( roomId = room.id!!, character = characterDto, messages = items, - hasMoreMessages = hasMore + hasMoreMessages = hasMore, + totalRemaining = quotaStatus.totalRemaining, + nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis ) } @@ -490,7 +497,7 @@ class ChatRoomService( } @Transactional - fun sendMessage(member: Member, chatRoomId: Long, message: String): List { + fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { // 1) 방 존재 확인 val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") @@ -512,7 +519,10 @@ class ChatRoomService( val sessionId = room.sessionId val characterUUID = character.characterUUID - // 5) 외부 API 호출 (최대 3회 재시도) + // 5) 쿼터 확인 및 차감 + chatQuotaService.consumeOne(member.id!!) + + // 6) 외부 API 호출 (최대 3회 재시도) val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) // 6) 내 메시지 저장 @@ -553,6 +563,8 @@ class ChatRoomService( hasAccess = true ) + val status = chatQuotaService.getStatus(member.id!!) + // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) if (matchedImage != null) { @@ -579,10 +591,18 @@ class ChatRoomService( ) val imageDto = toChatMessageItemDto(imageMsg, member) - return listOf(textDto, imageDto) + return SendChatMessageResponse( + messages = listOf(textDto, imageDto), + totalRemaining = status.totalRemaining, + nextRechargeAtEpoch = status.nextRechargeAtEpochMillis + ) } - return listOf(textDto) + return SendChatMessageResponse( + messages = listOf(textDto), + totalRemaining = status.totalRemaining, + nextRechargeAtEpoch = status.nextRechargeAtEpochMillis + ) } private fun toChatMessageItemDto( From 048c48d754abce6a44b42e3f8af4a317bbbb1890 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 13:57:02 +0900 Subject: [PATCH 093/119] =?UTF-8?q?fix(quota)!:=20AI=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=BF=BC=ED=84=B0(=EB=AC=B4=EB=A3=8C/=EC=9C=A0=EB=A3=8C)=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=20Response=EB=A5=BC=20ChatQuotaStatusRespons?= =?UTF-8?q?e=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/quota/ChatQuotaController.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index b7fe447..c64992f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -24,9 +24,7 @@ class ChatQuotaController( ) data class ChatQuotaPurchaseRequest( - val container: String, - val addPaid: Int = 50, - val needCan: Int = 30 + val container: String ) @GetMapping("/me") @@ -44,7 +42,7 @@ class ChatQuotaController( fun purchaseQuota( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @RequestBody request: ChatQuotaPurchaseRequest - ): ApiResponse = run { + ): ApiResponse = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") if (request.container.isBlank()) throw SodaException("container를 확인해주세요.") @@ -52,14 +50,16 @@ class ChatQuotaController( // 30캔 차감 처리 (결제 기록 남김) canPaymentService.spendCan( memberId = member.id!!, - needCan = if (request.needCan > 0) request.needCan else 30, + needCan = 30, canUsage = CanUsage.CHAT_QUOTA_PURCHASE, container = request.container ) // 유료 횟수 적립 (기본 50) - val add = if (request.addPaid > 0) request.addPaid else 50 + val add = 50 chatQuotaService.purchase(member.id!!, add) - ApiResponse.ok(true) + + val s = chatQuotaService.getStatus(member.id!!) + ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) } } From fcb68be0065343b2e450142bf7da273727506b5c Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 14:57:57 +0900 Subject: [PATCH 094/119] =?UTF-8?q?fix(chat-room):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=9E=85=EC=9E=A5=20-=20AI=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=BF=BC=ED=84=B0=20Lazy=20refill=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20read/write=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20Tra?= =?UTF-8?q?nsaction=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/chat/room/service/ChatRoomService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 6437c41..34d852e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -297,7 +297,7 @@ class ChatRoomService( return fetchSessionActive(room.sessionId) } - @Transactional(readOnly = true) + @Transactional fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") From 37ac52116a52ba0fe06f713fadaf17d275f1c7a5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 17:00:19 +0900 Subject: [PATCH 095/119] =?UTF-8?q?temp(quota):=20=EA=B8=B0=EB=8B=A4?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EB=AC=B4=EB=A3=8C=20=EC=BF=BC=ED=84=B0=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC?= =?UTF-8?q?=20=EC=9C=84=ED=95=B4=20=EC=9E=84=EC=8B=9C=EB=A1=9C=201?= =?UTF-8?q?=EB=B6=84=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../co/vividnext/sodalive/chat/quota/ChatQuotaService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 6309957..1e3ba87 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -12,7 +12,7 @@ class ChatQuotaService( ) { companion object { private const val FREE_BUCKET = 10 - private const val RECHARGE_HOURS = 6L + private const val RECHARGE_HOURS = 1L } data class QuotaStatus( @@ -41,20 +41,20 @@ class ChatQuotaService( quota.remainingFree > 0 -> { quota.remainingFree -= 1 if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) } } quota.remainingPaid > 0 -> { quota.remainingPaid -= 1 if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) } } else -> { if (quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusHours(RECHARGE_HOURS) + quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) } throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") } From a096b169454c5d1ad59c824c2c959c0ae7f9dd8d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 17:06:35 +0900 Subject: [PATCH 096/119] =?UTF-8?q?fix(quota):=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=BF=BC=ED=84=B0=20=EA=B5=AC=EB=A7=A4=20-=20nextRechargeAt=20?= =?UTF-8?q?=3D=20null=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 1e3ba87..69964fd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -76,5 +76,6 @@ class ChatQuotaService( fun purchase(memberId: Long, addPaid: Int) { val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) quota.remainingPaid += addPaid + quota.nextRechargeAt = null } } From 84ebc1762ba6f0efb8687a654a842c4eee267562 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 17:28:06 +0900 Subject: [PATCH 097/119] =?UTF-8?q?fix(quota):=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=BF=BC=ED=84=B0=20=EA=B5=AC=EB=A7=A4=20=EC=8B=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=82=B4=EC=97=AD=20=EB=AC=B8=EA=B5=AC=20-=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=ED=86=A1=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=EA=B6=8C=20=EA=B5=AC=EB=A7=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 7038575..55b6841 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -74,7 +74,7 @@ class CanService(private val repository: CanRepository) { CanUsage.AUDITION_VOTE -> "[오디션 투표] ${it.auditionApplicant?.role?.audition?.title ?: ""}" CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" - CanUsage.CHAT_QUOTA_PURCHASE -> "AI 채팅 개수 구매" + CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" } val createdAt = it.createdAt!! From 15d0952de86e2ead1c7af1d44b00b7a8f61768a1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 17:32:00 +0900 Subject: [PATCH 098/119] =?UTF-8?q?fix(quota):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=ED=86=A1=20=EC=B1=84=ED=8C=85=20=EC=BF=BC=ED=84=B0?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20-=20applyRefillOnEnterAndGetStatus?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=BF=BC=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C?= =?UTF-8?q?=20Lazy=20Refill=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/chat/quota/ChatQuotaService.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 69964fd..80b6ccf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -61,15 +61,9 @@ class ChatQuotaService( } } - @Transactional(readOnly = true) + @Transactional fun getStatus(memberId: Long): QuotaStatus { - val q = repo.findByMemberId(memberId) ?: return QuotaStatus( - totalRemaining = FREE_BUCKET, - nextRechargeAtEpochMillis = null - ) - val total = q.remainingFree + q.remainingPaid - val epoch = q.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() - return QuotaStatus(totalRemaining = total, nextRechargeAtEpochMillis = epoch) + return applyRefillOnEnterAndGetStatus(memberId) } @Transactional From 48b0190242cc45a7bf9f359a4f965cabbe57ebd2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 26 Aug 2025 23:52:30 +0900 Subject: [PATCH 099/119] =?UTF-8?q?feat(character-image):=20=EB=B3=B4?= =?UTF-8?q?=EC=9C=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20API=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DB?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/chat/character/image/my-list 엔드포인트 추가 - 로그인/본인인증 체크 - 캐릭터 프로필 이미지를 리스트 맨 앞에 포함 - 보유 이미지(무료 또는 구매 이력 존재)만 노출 - CloudFront 서명 URL 발급로 접근 제어 - 페이징 로직 개선 - 기존: 전체 조회 후 메모리에서 필터링/슬라이싱 - 변경: QueryDSL로 DB 레벨에서 보유 이미지만 오프셋/리밋 조회 - 프로필 아이템(인덱스 0) 포함을 고려하여 owned offset/limit 계산 - 빈 페이지 요청 시 즉시 빈 결과 반환 - Repository - CharacterImageQueryRepository + Impl 추가 - findOwnedActiveImagesByCharacterPaged(...) 구현 - 구매 이력: CHAT_MESSAGE_PURCHASE, CHARACTER_IMAGE_PURCHASE만 인정, 환불 제외 - 활성 이미지, sortOrder asc, id asc 정렬 + offset/limit - Service - getCharacterImagePath(characterId) 추가 - pageOwnedActiveByCharacterForMember(...) 추가 - Controller - my-list 응답 스키마는 list와 동일하게 totalCount/ownedCount/items 유지 - 페이지 사이즈 상한 20 적용, 5분 만료 서명 URL --- .../image/CharacterImageController.kt | 77 ++++++++++++++++++- .../image/CharacterImageRepository.kt | 53 ++++++++++++- .../character/image/CharacterImageService.kt | 17 ++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index 97337e0..c131b73 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -45,7 +45,6 @@ class CharacterImageController( val pageResult = imageService.pageActiveByCharacter(characterId, pageable) val totalCount = pageResult.totalElements - // 현재 요구사항 기준 '무료=보유'로 계산 (구매 이력은 추후 확장) val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) val expiration = 5L * 60L * 1000L // 5분 @@ -74,6 +73,82 @@ class CharacterImageController( ) } + @GetMapping("/my-list") + fun myList( + @RequestParam characterId: Long, + @RequestParam(required = false, defaultValue = "0") page: Int, + @RequestParam(required = false, defaultValue = "20") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val pageSize = if (size <= 0) 20 else minOf(size, 20) + val expiration = 5L * 60L * 1000L // 5분 + + val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + val totalCount = ownedCount + 1 // 프로필 포함 + + // 빈 페이지 요청 처리 + val startIndex = page * pageSize + if (startIndex >= totalCount) { + return@run ApiResponse.ok( + CharacterImageListResponse( + totalCount = totalCount, + ownedCount = ownedCount, + items = emptyList() + ) + ) + } + + val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt()) + val pageLength = endExclusive - startIndex + + // 프로필 이미지 경로 및 아이템 + val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png" + val profileItem = CharacterImageListItemResponse( + id = 0L, + imageUrl = "$imageHost/$profilePath", + isOwned = true, + imagePriceCan = 0L, + sortOrder = 0 + ) + + // 보유 이미지의 오프셋/리밋 계산 (결합 리스트 [프로필] + ownedImages) + val ownedOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong() + val ownedLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong() + + val ownedImagesPage = if (ownedLimit > 0) { + imageService.pageOwnedActiveByCharacterForMember(characterId, member.id!!, ownedOffset, ownedLimit) + } else { + emptyList() + } + + val items = buildList { + if (startIndex == 0 && pageLength > 0) add(profileItem) + ownedImagesPage.forEach { img -> + val url = imageCloudFront.generateSignedURL(img.imagePath, expiration) + add( + CharacterImageListItemResponse( + id = img.id!!, + imageUrl = url, + isOwned = true, + imagePriceCan = img.imagePriceCan, + sortOrder = img.sortOrder + ) + ) + } + } + + ApiResponse.ok( + CharacterImageListResponse( + totalCount = totalCount, + ownedCount = ownedCount, + items = items + ) + ) + } + @PostMapping("/purchase") fun purchase( @RequestBody req: CharacterImagePurchaseRequest, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt index 8337da6..c8a4e0e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -1,5 +1,9 @@ package kr.co.vividnext.sodalive.chat.character.image +import com.querydsl.jpa.JPAExpressions +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.QUseCan.useCan import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository @@ -7,7 +11,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface CharacterImageRepository : JpaRepository { +interface CharacterImageRepository : JpaRepository, CharacterImageQueryRepository { fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc( @@ -23,3 +27,50 @@ interface CharacterImageRepository : JpaRepository { ) fun findMaxSortOrderByCharacterId(characterId: Long): Int } + +interface CharacterImageQueryRepository { + fun findOwnedActiveImagesByCharacterPaged( + characterId: Long, + memberId: Long, + offset: Long, + limit: Long + ): List +} + +class CharacterImageQueryRepositoryImpl( + private val queryFactory: JPAQueryFactory +) : CharacterImageQueryRepository { + override fun findOwnedActiveImagesByCharacterPaged( + characterId: Long, + memberId: Long, + offset: Long, + limit: Long + ): List { + val usages = listOf(CanUsage.CHAT_MESSAGE_PURCHASE, CanUsage.CHARACTER_IMAGE_PURCHASE) + val ci = QCharacterImage.characterImage + return queryFactory + .selectFrom(ci) + .where( + ci.chatCharacter.id.eq(characterId) + .and(ci.isActive.isTrue) + .and( + ci.imagePriceCan.eq(0L).or( + JPAExpressions + .selectOne() + .from(useCan) + .where( + useCan.member.id.eq(memberId) + .and(useCan.isRefund.isFalse) + .and(useCan.characterImage.id.eq(ci.id)) + .and(useCan.canUsage.`in`(usages)) + ) + .exists() + ) + ) + ) + .orderBy(ci.sortOrder.asc(), ci.id.asc()) + .offset(offset) + .limit(limit) + .fetch() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index a97c9df..751b814 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -56,6 +56,23 @@ class CharacterImageService( fun getById(id: Long): CharacterImage = imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } + fun getCharacterImagePath(characterId: Long): String? { + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + return character.imagePath + } + + // 보유한(무료+구매) 활성 이미지 페이징 조회 + fun pageOwnedActiveByCharacterForMember( + characterId: Long, + memberId: Long, + offset: Long, + limit: Long + ): List { + if (limit <= 0L) return emptyList() + return imageRepository.findOwnedActiveImagesByCharacterPaged(characterId, memberId, offset, limit) + } + @Transactional fun registerImage( characterId: Long, From 0347d767f0e2db9a6bb20e883353efc0729fc3b7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 14:22:07 +0900 Subject: [PATCH 100/119] =?UTF-8?q?feat(character-image):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B2=AB=20=EC=B9=B8=EC=97=90=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 경험 향상을 위해 캐릭터 프로필 이미지를 이미지 리스트의 맨 앞에 노출하도록 변경. --- .../image/CharacterImageController.kt | 79 ++++++++++++++----- .../image/CharacterImageRepository.kt | 24 ++++++ .../character/image/CharacterImageService.kt | 10 +++ 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index c131b73..057ca9c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -40,30 +40,73 @@ class CharacterImageController( if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") val pageSize = if (size <= 0) 20 else minOf(size, 20) - val pageable = PageRequest.of(page, pageSize) - - val pageResult = imageService.pageActiveByCharacter(characterId, pageable) - val totalCount = pageResult.totalElements + // 전체 활성 이미지 수(프로필 제외) 파악을 위해 최소 페이지 조회 + val totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) - val expiration = 5L * 60L * 1000L // 5분 - val items = pageResult.content.map { img -> - val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!) - val url = if (isOwned) { - imageCloudFront.generateSignedURL(img.imagePath, expiration) - } else { - "$imageHost/${img.blurImagePath}" - } - CharacterImageListItemResponse( - id = img.id!!, - imageUrl = url, - isOwned = isOwned, - imagePriceCan = img.imagePriceCan, - sortOrder = img.sortOrder + val totalCount = totalActiveElements + 1 // 프로필 포함 + + val startIndex = page * pageSize + if (startIndex >= totalCount) { + return@run ApiResponse.ok( + CharacterImageListResponse( + totalCount = totalCount, + ownedCount = ownedCount, + items = emptyList() + ) ) } + val endExclusive = kotlin.math.min(startIndex + pageSize, totalCount.toInt()) + val pageLength = endExclusive - startIndex + + // 프로필 이미지 구성(맨 앞) + val profilePath = imageService.getCharacterImagePath(characterId) ?: "profile/default-profile.png" + val profileItem = CharacterImageListItemResponse( + id = 0L, + imageUrl = "$imageHost/$profilePath", + isOwned = true, + imagePriceCan = 0L, + sortOrder = 0 + ) + + // 활성 이미지 offset/limit 계산 (결합 리스트 [프로필] + activeImages) + val activeOffset = if (startIndex == 0) 0L else (startIndex - 1).toLong() + val activeLimit = if (startIndex == 0) (pageLength - 1).toLong() else pageLength.toLong() + + val expiration = 5L * 60L * 1000L // 5분 + val activeImages = if (activeLimit > 0) { + imageService.pageActiveByCharacterOffset( + characterId, + activeOffset, + activeLimit + ) + } else { + emptyList() + } + + val items = buildList { + if (startIndex == 0 && pageLength > 0) add(profileItem) + activeImages.forEach { img -> + val isOwned = (img.imagePriceCan == 0L) || imageService.isOwnedImageByMember(img.id!!, member.id!!) + val url = if (isOwned) { + imageCloudFront.generateSignedURL(img.imagePath, expiration) + } else { + "$imageHost/${img.blurImagePath}" + } + add( + CharacterImageListItemResponse( + id = img.id!!, + imageUrl = url, + isOwned = isOwned, + imagePriceCan = img.imagePriceCan, + sortOrder = img.sortOrder + ) + ) + } + } + ApiResponse.ok( CharacterImageListResponse( totalCount = totalCount, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt index c8a4e0e..f23c7e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageRepository.kt @@ -35,6 +35,12 @@ interface CharacterImageQueryRepository { offset: Long, limit: Long ): List + + fun findActiveImagesByCharacterPaged( + characterId: Long, + offset: Long, + limit: Long + ): List } class CharacterImageQueryRepositoryImpl( @@ -73,4 +79,22 @@ class CharacterImageQueryRepositoryImpl( .limit(limit) .fetch() } + + override fun findActiveImagesByCharacterPaged( + characterId: Long, + offset: Long, + limit: Long + ): List { + val ci = QCharacterImage.characterImage + return queryFactory + .selectFrom(ci) + .where( + ci.chatCharacter.id.eq(characterId) + .and(ci.isActive.isTrue) + ) + .orderBy(ci.sortOrder.asc(), ci.id.asc()) + .offset(offset) + .limit(limit) + .fetch() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt index 751b814..b0bbe98 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageService.kt @@ -26,6 +26,16 @@ class CharacterImageService( return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId, pageable) } + // 오프셋/리밋 조회(활성 이미지) + fun pageActiveByCharacterOffset( + characterId: Long, + offset: Long, + limit: Long + ): List { + if (limit <= 0L) return emptyList() + return imageRepository.findActiveImagesByCharacterPaged(characterId, offset, limit) + } + // 구매 이력 + 무료로 계산된 보유 수 fun countOwnedActiveByCharacterForMember(characterId: Long, memberId: Long): Long { val freeCount = imageRepository.countByChatCharacterIdAndIsActiveTrueAndImagePriceCan(characterId, 0L) From 258943535cb263cc6e87716f2f3e94d0a59d1252 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 15:18:24 +0900 Subject: [PATCH 101/119] =?UTF-8?q?feat(chat-room):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=9E=85=EC=9E=A5=20=EC=8B=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=84=9C=EB=AA=85=20URL=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter API에 characterImageId 선택 파라미터 추가 동일 캐릭터/활성 여부/보유 여부 검증 후 5분 만료의 CloudFront 서명 URL 생성 ChatRoomEnterResponse에 bgImageUrl 필드 추가해 응답 포함 서명 URL 생성 실패 시 warn 로그만 남기고 null 반환하여 사용자 흐름 유지 기존 호출은 그대로 동작하며, 파라미터와 응답 필드 추가는 하위 호환됨 --- .../room/controller/ChatRoomController.kt | 5 +-- .../sodalive/chat/room/dto/ChatRoomDto.kt | 3 +- .../chat/room/service/ChatRoomService.kt | 36 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index f5bd481..aa0f1f1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -91,12 +91,13 @@ class ChatRoomController( @GetMapping("/{chatRoomId}/enter") fun enterChatRoom( @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, - @PathVariable chatRoomId: Long + @PathVariable chatRoomId: Long, + @RequestParam(required = false) characterImageId: Long? ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") - val response = chatRoomService.enterChatRoom(member, chatRoomId) + val response = chatRoomService.enterChatRoom(member, chatRoomId, characterImageId) ApiResponse.ok(response) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index b43d8a7..b09f230 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -182,7 +182,8 @@ data class ChatRoomEnterResponse( val messages: List, val hasMoreMessages: Boolean, val totalRemaining: Int, - val nextRechargeAtEpoch: Long? + val nextRechargeAtEpoch: Long?, + val bgImageUrl: String? = null ) /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 34d852e..50bd17b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -298,7 +298,7 @@ class ChatRoomService( } @Transactional - fun enterChatRoom(member: Member, chatRoomId: Long): ChatRoomEnterResponse { + fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") } @@ -340,13 +340,45 @@ class ChatRoomService( // 입장 시 Lazy refill 적용 후 상태 반환 val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) + // 선택적 캐릭터 이미지 서명 URL 생성 처리 + val signedUrl: String? = try { + if (characterImageId != null) { + val img = characterImageService.getById(characterImageId) + // 동일 캐릭터 소속 및 활성 검증 + if (img.chatCharacter.id == character.id && img.isActive) { + val owned = + (img.imagePriceCan == 0L) || characterImageService.isOwnedImageByMember(img.id!!, member.id!!) + if (owned) { + val expiration = 5L * 60L * 1000L // 5분 + imageCloudFront.generateSignedURL(img.imagePath, expiration) + } else { + null + } + } else { + null + } + } else { + null + } + } catch (e: Exception) { + // 문제가 있어도 입장 자체는 가능해야 하므로 로그만 남기고 null 반환 + log.warn( + "[chat] enter: signed url generation failed. roomId={}, imageId={}, reason={}", + room.id, + characterImageId, + e.message + ) + null + } + return ChatRoomEnterResponse( roomId = room.id!!, character = characterDto, messages = items, hasMoreMessages = hasMore, totalRemaining = quotaStatus.totalRemaining, - nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis + nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis, + bgImageUrl = signedUrl ) } From 42ed4692af53b92969a55c1a630cabca1335ea80 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 17:16:18 +0900 Subject: [PATCH 102/119] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=B4=88=EA=B8=B0=ED=99=94=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/chat/room/{chatRoomId}/reset POST 엔드포인트 추가 - 초기화 절차: 30캔 결제 → 기존 방 나가기 → 동일 캐릭터로 새 방 생성 → 응답 반환 - 결제 시 CanUsage.CHAT_ROOM_RESET 신규 항목 사용(본인 귀속) - ChatQuotaService.resetFreeToDefault 추가 및 초기화 성공 시 무료 10회로 리셋(nextRechargeAt 초기화) - 사용내역 타이틀에 "캐릭터 톡 초기화" 노출(CanService) - ChatRoomResetRequest DTO(container 포함) 추가 - leaveChatRoom에 throwOnSessionEndFailure 옵션 추가(기본 false 유지) - endExternalSession에 throwOnFailure 옵션 추가: 최대 3회 재시도 후 실패 시 예외 전파 가능 - 채팅방 초기화 흐름에서는 외부 세션 종료 실패 시 예외를 던져 트랜잭션 롤백되도록 처리 --- .../co/vividnext/sodalive/can/CanService.kt | 1 + .../sodalive/can/payment/CanPaymentService.kt | 3 + .../co/vividnext/sodalive/can/use/CanUsage.kt | 3 +- .../sodalive/chat/quota/ChatQuotaService.kt | 7 +++ .../vividnext/sodalive/chat/room/ChatRoom.kt | 2 +- .../room/controller/ChatRoomController.kt | 20 +++++++ .../sodalive/chat/room/dto/ChatRoomDto.kt | 7 +++ .../chat/room/service/ChatRoomService.kt | 57 +++++++++++++++++-- 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt index 55b6841..555cc6e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt @@ -75,6 +75,7 @@ class CanService(private val repository: CanRepository) { CanUsage.CHAT_MESSAGE_PURCHASE -> "[메시지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHARACTER_IMAGE_PURCHASE -> "[캐릭터 이미지 구매] ${it.characterImage?.chatCharacter?.name ?: ""}" CanUsage.CHAT_QUOTA_PURCHASE -> "캐릭터 톡 이용권 구매" + CanUsage.CHAT_ROOM_RESET -> "캐릭터 톡 초기화" } val createdAt = it.createdAt!! diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index c9d2498..ce57b14 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -113,6 +113,9 @@ class CanPaymentService( } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 useCan.member = member + } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { + // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 + useCan.member = member } else { throw SodaException("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt index 4f06828..44bcbd5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt @@ -12,5 +12,6 @@ enum class CanUsage { AUDITION_VOTE, CHAT_MESSAGE_PURCHASE, // 메시지를 통한 구매(이미지 등 다양한 리소스에 공통 적용) CHARACTER_IMAGE_PURCHASE, // 캐릭터 이미지 단독 구매 - CHAT_QUOTA_PURCHASE // 채팅 횟수(쿼터) 충전 + CHAT_QUOTA_PURCHASE, // 채팅 횟수(쿼터) 충전 + CHAT_ROOM_RESET // 채팅방 초기화 결제(별도 구분) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index 80b6ccf..d62c822 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -72,4 +72,11 @@ class ChatQuotaService( quota.remainingPaid += addPaid quota.nextRechargeAt = null } + + @Transactional + fun resetFreeToDefault(memberId: Long) { + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + quota.remainingFree = FREE_BUCKET + quota.nextRechargeAt = null + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt index ff8e9d0..65caa00 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/ChatRoom.kt @@ -10,7 +10,7 @@ import javax.persistence.OneToMany class ChatRoom( val sessionId: String, val title: String, - val isActive: Boolean = true + var isActive: Boolean = true ) : BaseEntity() { @OneToMany(mappedBy = "chatRoom", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) val messages: MutableList = mutableListOf() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt index aa0f1f1..7434207 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/controller/ChatRoomController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.room.controller import kr.co.vividnext.sodalive.chat.room.dto.ChatMessagePurchaseRequest +import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomResetRequest import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService @@ -181,4 +182,23 @@ class ChatRoomController( val result = chatRoomService.purchaseMessage(member, chatRoomId, messageId, request.container) ApiResponse.ok(result) } + + /** + * 채팅방 초기화 API + * - 로그인 및 본인인증 확인 + * - 내가 참여 중인 AI 캐릭터 채팅방인지 확인 + * - 30캔 결제 → 현재 채팅방 나가기 → 동일 캐릭터와 새 채팅방 생성 → 생성된 채팅방 데이터 반환 + */ + @PostMapping("/{chatRoomId}/reset") + fun resetChatRoom( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestBody request: ChatRoomResetRequest + ) = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val response = chatRoomService.resetChatRoom(member, chatRoomId, request.container) + ApiResponse.ok(response) + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index b09f230..df80d89 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -194,3 +194,10 @@ data class SendChatMessageResponse( val totalRemaining: Int, val nextRechargeAtEpoch: Long? ) + +/** + * 채팅방 초기화 요청 DTO + */ +data class ChatRoomResetRequest( + val container: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 50bd17b..8458fdb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.chat.room.service import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService @@ -422,7 +423,7 @@ class ChatRoomService( } @Transactional - fun leaveChatRoom(member: Member, chatRoomId: Long) { + fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { val room = chatRoomRepository.findById(chatRoomId).orElseThrow { SodaException("채팅방을 찾을 수 없습니다.") } @@ -441,12 +442,13 @@ class ChatRoomService( // 3) 내가 마지막 USER였다면 외부 세션 종료 if (userCount == 0L) { - endExternalSession(room.sessionId) + endExternalSession(room.sessionId, throwOnFailure = throwOnSessionEndFailure) + room.isActive = false } } - private fun endExternalSession(sessionId: String) { - // 사용자 흐름을 방해하지 않기 위해 실패 시 예외를 던지지 않고 내부 재시도 후 로그만 남깁니다. + private fun endExternalSession(sessionId: String, throwOnFailure: Boolean = false) { + // 기본 동작: 내부 재시도. throwOnFailure=true일 때는 최종 실패 시 예외 전파. val maxAttempts = 3 var attempt = 0 while (attempt < maxAttempts) { @@ -489,8 +491,14 @@ class ChatRoomService( log.warn("[chat] 외부 세션 종료 중 예외: sessionId={}, attempt={}, message={}", sessionId, attempt, e.message) } } - // 최종 실패 로그 (예외 미전파) - log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) + // 최종 실패 처리 + val message = "채팅방 세션 종료에 실패했습니다. 다시 시도해 주세요." + if (throwOnFailure) { + log.error("[chat] 외부 세션 종료 최종 실패(예외 전파): sessionId={}, attempts={}", sessionId, maxAttempts) + throw SodaException(message) + } else { + log.error("[chat] 외부 세션 종료 최종 실패: sessionId={}, attempts={}", sessionId, maxAttempts) + } } @Transactional(readOnly = true) @@ -774,4 +782,41 @@ class ChatRoomService( } return null } + + @Transactional + fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse { + // 0) 방 존재 및 내 참여 여부 확인 + val room = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + + // 1) AI 캐릭터 채팅방인지 확인 (CHARACTER 타입의 활성 참여자 존재 확인) + val characterParticipant = participantRepository + .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + val character = characterParticipant.character + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + + // 2) 30캔 결제 (채팅방 초기화 전용 CanUsage 사용) + canPaymentService.spendCan( + memberId = member.id!!, + needCan = 30, + canUsage = CanUsage.CHAT_ROOM_RESET, + container = container + ) + + // 3) 현재 채팅방 나가기 (세션 종료 실패 시 롤백되도록 설정) + leaveChatRoom(member, chatRoomId, true) + + // 4) 동일한 캐릭터와 새로운 채팅방 생성 + val created = createOrGetChatRoom(member, character.id!!) + + // 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정 + chatQuotaService.resetFreeToDefault(member.id!!) + + // 6) 생성된 채팅방 데이터 반환 + return created + } } From c4dbdc1b8e059500bdd560e42cd613bc038005bf Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 17:43:32 +0900 Subject: [PATCH 103/119] =?UTF-8?q?fix(chat-room):=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이 변경으로 비활성화된 채팅방에 대한 메시지 전송/조회/입장/리셋 등 모든 경로에서 안전하게 접근이 차단됩니다. --- .../room/repository/ChatRoomRepository.kt | 2 ++ .../chat/room/service/ChatRoomService.kt | 35 ++++++++----------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index f161cd8..a5f3d6c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -62,4 +62,6 @@ interface ChatRoomRepository : JpaRepository { @Param("member") member: Member, pageable: Pageable ): List + + fun findByIdAndIsActiveTrue(id: Long): ChatRoom? } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 8458fdb..975310b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -69,9 +69,8 @@ class ChatRoomService( @Transactional fun purchaseMessage(member: Member, chatRoomId: Long, messageId: Long, container: String): ChatMessageItemDto { - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") // 참여 여부 검증 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") @@ -288,9 +287,8 @@ class ChatRoomService( @Transactional(readOnly = true) fun isMyRoomSessionActive(member: Member, chatRoomId: Long): Boolean { - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) if (participant == null) { throw SodaException("잘못된 접근입니다") @@ -300,9 +298,8 @@ class ChatRoomService( @Transactional fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse { - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") // 참여 여부 검증 participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") @@ -424,9 +421,8 @@ class ChatRoomService( @Transactional fun leaveChatRoom(member: Member, chatRoomId: Long, throwOnSessionEndFailure: Boolean = false) { - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") val participant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") @@ -503,9 +499,8 @@ class ChatRoomService( @Transactional(readOnly = true) fun getChatMessages(member: Member, chatRoomId: Long, cursor: Long?, limit: Int = 20): ChatMessagesPageResponse { - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") @@ -539,9 +534,8 @@ class ChatRoomService( @Transactional fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse { // 1) 방 존재 확인 - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") // 2) 참여 여부 확인 (USER) val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") @@ -786,9 +780,8 @@ class ChatRoomService( @Transactional fun resetChatRoom(member: Member, chatRoomId: Long, container: String): CreateChatRoomResponse { // 0) 방 존재 및 내 참여 여부 확인 - val room = chatRoomRepository.findById(chatRoomId).orElseThrow { - SodaException("채팅방을 찾을 수 없습니다.") - } + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) ?: throw SodaException("잘못된 접근입니다") From 2c3e12a42c675a0b23ae72d7d0045bc4504d5630 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 27 Aug 2025 19:18:46 +0900 Subject: [PATCH 104/119] =?UTF-8?q?fix(chat-room):=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=99=B8=EB=B6=80=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContentType 설정 제거 --- .../co/vividnext/sodalive/chat/room/service/ChatRoomService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 975310b..4a6ffad 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -458,7 +458,6 @@ class ChatRoomService( val headers = HttpHeaders() headers.set("x-api-key", apiKey) - headers.contentType = MediaType.APPLICATION_JSON val httpEntity = HttpEntity(null, headers) From a94cf8dad97f9aadf5b054e03dc6c65e39b274b6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 00:18:21 +0900 Subject: [PATCH 105/119] =?UTF-8?q?feat(chat):=20=EC=9E=85=EC=9E=A5=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=8B=9C=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EB=AC=B4=EC=8B=9C(null)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - baseRoom이 비활성/미참여면 동일 캐릭터의 내 활성 방으로 라우팅해 응답 구성 - 라우팅된 경우 bgImageUrl은 항상 null 처리; 대체 방 없으면 기존 예외 유지 --- .../chat/room/service/ChatRoomService.kt | 113 ++++++++++++------ 1 file changed, 77 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 4a6ffad..a235ae0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -298,17 +298,49 @@ class ChatRoomService( @Transactional fun enterChatRoom(member: Member, chatRoomId: Long, characterImageId: Long? = null): ChatRoomEnterResponse { - val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) - ?: throw SodaException("채팅방을 찾을 수 없습니다.") - // 참여 여부 검증 - participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) - ?: throw SodaException("잘못된 접근입니다") + // 1) 활성 여부 무관하게 방 조회 + val baseRoom = chatRoomRepository.findById(chatRoomId).orElseThrow { + SodaException("채팅방을 찾을 수 없습니다.") + } - // 캐릭터 참여자 조회 - val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( - room, + // 2) 기본 방 기준 참여/활성 여부 확인 + val isActiveRoom = baseRoom.isActive + val isMyActiveParticipation = + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(baseRoom, member) != null + + // 3) 기본 방의 캐릭터 식별 (활성 우선, 없으면 컬렉션에서 검색) + val baseCharacterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( + baseRoom, ParticipantType.CHARACTER - ) ?: throw SodaException("잘못된 접근입니다") + ) ?: baseRoom.participants.firstOrNull { + it.participantType == ParticipantType.CHARACTER + } ?: throw SodaException("잘못된 접근입니다") + + val baseCharacter = baseCharacterParticipant.character + ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") + + // 4) 유효한 입장 대상 방 결정 + val effectiveRoom: ChatRoom = if (isActiveRoom && isMyActiveParticipation) { + baseRoom + } else { + // 동일 캐릭터 + 내가 참여 중인 활성 방을 찾는다 + val alt = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, baseCharacter) + alt ?: ( // 대체 방이 없으면 기존과 동일하게 예외 처리 + if (!isActiveRoom) { + throw SodaException("채팅방을 찾을 수 없습니다.") + } else { + throw SodaException("잘못된 접근입니다") + } + ) + } + + // 5) 응답 구성 시에는 effectiveRoom의 캐릭터(활성 우선) 사용 + val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue( + effectiveRoom, + ParticipantType.CHARACTER + ) ?: effectiveRoom.participants.firstOrNull { + it.participantType == ParticipantType.CHARACTER + } ?: throw SodaException("잘못된 접근입니다") val character = characterParticipant.character ?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.") @@ -321,13 +353,13 @@ class ChatRoomService( characterType = character.characterType.name ) - // 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 + // 메시지 최신 20개 조회 후 createdAt 오름차순으로 반환 (effectiveRoom 기준) val pageable = PageRequest.of(0, 20) - val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(room, pageable) + val fetched = messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(effectiveRoom, pageable) val nextCursor: Long? = fetched.minByOrNull { it.id ?: Long.MAX_VALUE }?.id val hasMore: Boolean = if (nextCursor != null) { - messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(room, nextCursor) + messageRepository.existsByChatRoomAndIsActiveTrueAndIdLessThan(effectiveRoom, nextCursor) } else { false } @@ -339,38 +371,47 @@ class ChatRoomService( val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) // 선택적 캐릭터 이미지 서명 URL 생성 처리 - val signedUrl: String? = try { - if (characterImageId != null) { - val img = characterImageService.getById(characterImageId) - // 동일 캐릭터 소속 및 활성 검증 - if (img.chatCharacter.id == character.id && img.isActive) { - val owned = - (img.imagePriceCan == 0L) || characterImageService.isOwnedImageByMember(img.id!!, member.id!!) - if (owned) { - val expiration = 5L * 60L * 1000L // 5분 - imageCloudFront.generateSignedURL(img.imagePath, expiration) + // 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리 + val signedUrl: String? = + if (effectiveRoom.id != baseRoom.id) { + null + } else { + try { + if (characterImageId != null) { + val img = characterImageService.getById(characterImageId) + // 동일 캐릭터 소속 및 활성 검증 + if (img.chatCharacter.id == character.id && img.isActive) { + val owned = + (img.imagePriceCan == 0L) || characterImageService.isOwnedImageByMember( + img.id!!, + member.id!! + ) + if (owned) { + val expiration = 5L * 60L * 1000L // 5분 + imageCloudFront.generateSignedURL(img.imagePath, expiration) + } else { + null + } + } else { + null + } } else { null } - } else { + } catch (e: Exception) { + // 문제가 있어도 입장 자체는 가능해야 하므로 로그만 남기고 null 반환 + log.warn( + "[chat] enter: signed url generation failed. roomId={}, imageId={}, reason={}", + effectiveRoom.id, + characterImageId, + e.message + ) null } - } else { - null } - } catch (e: Exception) { - // 문제가 있어도 입장 자체는 가능해야 하므로 로그만 남기고 null 반환 - log.warn( - "[chat] enter: signed url generation failed. roomId={}, imageId={}, reason={}", - room.id, - characterImageId, - e.message - ) - null - } return ChatRoomEnterResponse( - roomId = room.id!!, + roomId = effectiveRoom.id!!, character = characterDto, messages = items, hasMoreMessages = hasMore, From 0b54b126dbdf3bdafa5b3f1e2c278cf194b93260 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 00:21:07 +0900 Subject: [PATCH 106/119] =?UTF-8?q?temp(chat-character):=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=20=EC=BA=90=EB=A6=AD=ED=84=B0=2050=EA=B0=9C=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 --- .../chat/character/controller/ChatCharacterController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index f5d5c69..e7e8fb4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -75,7 +75,7 @@ class ChatCharacterController( } // 최신 캐릭터 조회 (최대 10개) - val newCharacters = service.getNewCharacters(30) + val newCharacters = service.getNewCharacters(50) .map { Character( characterId = it.id!!, From df93f0e0ce2b1fe5c224646cff6a661d8f14ec41 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 00:22:15 +0900 Subject: [PATCH 107/119] =?UTF-8?q?feat(chat-quota):=2030=EC=BA=94?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=A9=EC=A0=84=EC=8B=9C=20=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EC=B1=84=ED=8C=85=20=ED=9A=9F=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 50 -> 40으로 변경 --- .../co/vividnext/sodalive/chat/quota/ChatQuotaController.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index c64992f..fa0640e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -55,8 +55,8 @@ class ChatQuotaController( container = request.container ) - // 유료 횟수 적립 (기본 50) - val add = 50 + // 유료 횟수 적립 (기본 40) + val add = 40 chatQuotaService.purchase(member.id!!, add) val s = chatQuotaService.getStatus(member.id!!) From a58de0cf924fe35b0a8733ad8d7754b7e97368ef Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 02:33:04 +0900 Subject: [PATCH 108/119] =?UTF-8?q?feat(chat-room-list):=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A9=B4=20?= =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20[?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80]=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/room/service/ChatRoomService.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index a235ae0..ddf4279 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -251,8 +251,16 @@ class ChatRoomService( ).apply { id = q.chatRoomId } val latest = messageRepository.findTopByChatRoomAndIsActiveTrueOrderByCreatedAtDesc(room) - val preview = latest?.message?.let { msg -> - if (msg.length <= 30) msg else msg.take(30) + "..." + val preview = if (latest?.message?.isNotBlank() == true) { + latest.message.let { msg -> + if (msg.length <= 30) msg else msg.take(30) + "..." + } + } else { + if (latest?.message.isNullOrBlank() && latest?.characterImage != null) { + "[이미지]" + } else { + "" + } } val imageUrl = "$imageHost/${q.imagePath ?: "profile/default-profile.png"}" From 6767afdd3563748a9726f1f813304a1d02d551cd Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 17:39:53 +0900 Subject: [PATCH 109/119] =?UTF-8?q?feat(character-curation):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8/=EA=B4=80=EB=A6=AC=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=94=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterCuration/CharacterCurationMapping 엔티티 추가 - 리포지토리/서비스(조회·관리) 구현 - 관리자 컨트롤러에 등록/수정/삭제/정렬/캐릭터 추가·삭제·정렬 API 추가 - 앱 메인 API에 큐레이션 섹션 노출 - 정렬/소프트 삭제/활성 캐릭터 필터링 규칙 적용 --- .../CharacterCurationAdminController.kt | 76 +++++++++++ .../curation/CharacterCurationAdminDto.kt | 44 +++++++ .../curation/CharacterCurationAdminService.kt | 123 ++++++++++++++++++ .../controller/ChatCharacterController.kt | 19 ++- .../character/curation/CharacterCuration.kt | 47 +++++++ .../curation/CharacterCurationQueryService.kt | 37 ++++++ .../CharacterCurationMappingRepository.kt | 33 +++++ .../repository/CharacterCurationRepository.kt | 15 +++ 8 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt new file mode 100644 index 0000000..aa6870f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt @@ -0,0 +1,76 @@ +package kr.co.vividnext.sodalive.admin.chat.character.curation + +import kr.co.vividnext.sodalive.common.ApiResponse +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.RestController + +@RestController +@RequestMapping("/admin/chat/character/curation") +@PreAuthorize("hasRole('ADMIN')") +class CharacterCurationAdminController( + private val service: CharacterCurationAdminService, + @Value("\${cloud.aws.cloud-front.host}") + private val imageHost: String +) { + @GetMapping("/list") + fun listAll(): ApiResponse> = + ApiResponse.ok(service.listAll()) + + @GetMapping("/{curationId}/characters") + fun listCharacters( + @PathVariable curationId: Long + ): ApiResponse> { + val characters = service.listCharacters(curationId) + val items = characters.map { + CharacterCurationCharacterItemResponse( + id = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + return ApiResponse.ok(items) + } + + @PostMapping("/register") + fun register(@RequestBody request: CharacterCurationRegisterRequest) = + ApiResponse.ok(service.register(request).id) + + @PutMapping("/update") + fun update(@RequestBody request: CharacterCurationUpdateRequest) = + ApiResponse.ok(service.update(request).id) + + @DeleteMapping("/{curationId}") + fun delete(@PathVariable curationId: Long) = + ApiResponse.ok(service.softDelete(curationId)) + + @PutMapping("/reorder") + fun reorder(@RequestBody request: CharacterCurationOrderUpdateRequest) = + ApiResponse.ok(service.reorder(request.ids)) + + @PostMapping("/{curationId}/characters") + fun addCharacter( + @PathVariable curationId: Long, + @RequestBody request: CharacterCurationAddCharacterRequest + ) = ApiResponse.ok(service.addCharacter(curationId, request.characterId)) + + @DeleteMapping("/{curationId}/characters/{characterId}") + fun removeCharacter( + @PathVariable curationId: Long, + @PathVariable characterId: Long + ) = ApiResponse.ok(service.removeCharacter(curationId, characterId)) + + @PutMapping("/{curationId}/characters/reorder") + fun reorderCharacters( + @PathVariable curationId: Long, + @RequestBody request: CharacterCurationReorderCharactersRequest + ) = ApiResponse.ok(service.reorderCharacters(curationId, request.characterIds)) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt new file mode 100644 index 0000000..bb46b03 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt @@ -0,0 +1,44 @@ +package kr.co.vividnext.sodalive.admin.chat.character.curation + +data class CharacterCurationRegisterRequest( + val title: String, + val isAdult: Boolean = false, + val isActive: Boolean = true +) + +data class CharacterCurationUpdateRequest( + val id: Long, + val title: String? = null, + val isAdult: Boolean? = null, + val isActive: Boolean? = null +) + +data class CharacterCurationOrderUpdateRequest( + val ids: List +) + +data class CharacterCurationAddCharacterRequest( + val characterId: Long +) + +data class CharacterCurationReorderCharactersRequest( + val characterIds: List +) + +data class CharacterCurationListItemResponse( + val id: Long, + val title: String, + val isAdult: Boolean, + val isActive: Boolean +) + +// 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO +// id, name, description, 이미지 URL +// 이미지 URL은 컨트롤러에서 cloud-front host + imagePath로 구성 + +data class CharacterCurationCharacterItemResponse( + val id: Long, + val name: String, + val description: String, + val imageUrl: String +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt new file mode 100644 index 0000000..72b7690 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt @@ -0,0 +1,123 @@ +package kr.co.vividnext.sodalive.admin.chat.character.curation + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping +import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository +import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CharacterCurationAdminService( + private val curationRepository: CharacterCurationRepository, + private val mappingRepository: CharacterCurationMappingRepository, + private val characterRepository: ChatCharacterRepository +) { + + @Transactional + fun register(request: CharacterCurationRegisterRequest): CharacterCuration { + val sortOrder = (curationRepository.findMaxSortOrder() ?: 0) + 1 + val curation = CharacterCuration( + title = request.title, + isAdult = request.isAdult, + isActive = request.isActive, + sortOrder = sortOrder + ) + return curationRepository.save(curation) + } + + @Transactional + fun update(request: CharacterCurationUpdateRequest): CharacterCuration { + val curation = curationRepository.findById(request.id) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: ${request.id}") } + + request.title?.let { curation.title = it } + request.isAdult?.let { curation.isAdult = it } + request.isActive?.let { curation.isActive = it } + + return curationRepository.save(curation) + } + + @Transactional + fun softDelete(curationId: Long) { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + curation.isActive = false + curationRepository.save(curation) + } + + @Transactional + fun reorder(ids: List) { + ids.forEachIndexed { index, id -> + val curation = curationRepository.findById(id) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $id") } + curation.sortOrder = index + 1 + curationRepository.save(curation) + } + } + + @Transactional + fun addCharacter(curationId: Long, characterId: Long) { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId") + + val character = characterRepository.findById(characterId) + .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } + if (!character.isActive) throw SodaException("비활성화된 캐릭터는 추가할 수 없습니다: $characterId") + + val existing = mappingRepository.findByCuration(curation) + .firstOrNull { it.chatCharacter.id == characterId } + if (existing != null) return // 이미 존재하면 무시 + + val nextOrder = (mappingRepository.findByCuration(curation).maxOfOrNull { it.sortOrder } ?: 0) + 1 + val mapping = CharacterCurationMapping( + curation = curation, + chatCharacter = character, + sortOrder = nextOrder + ) + mappingRepository.save(mapping) + } + + @Transactional + fun removeCharacter(curationId: Long, characterId: Long) { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + val mappings = mappingRepository.findByCuration(curation) + val target = mappings.firstOrNull { it.chatCharacter.id == characterId } + ?: throw SodaException("매핑을 찾을 수 없습니다: curation=$curationId, character=$characterId") + mappingRepository.delete(target) + } + + @Transactional + fun reorderCharacters(curationId: Long, characterIds: List) { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + val mappings = mappingRepository.findByCuration(curation) + val mappingByCharacterId = mappings.associateBy { it.chatCharacter.id } + + characterIds.forEachIndexed { index, cid -> + val mapping = mappingByCharacterId[cid] + ?: throw SodaException("큐레이션에 포함되지 않은 캐릭터입니다: $cid") + mapping.sortOrder = index + 1 + mappingRepository.save(mapping) + } + } + + @Transactional(readOnly = true) + fun listAll(): List { + return curationRepository.findAllByOrderBySortOrderAsc() + .map { CharacterCurationListItemResponse(it.id!!, it.title, it.isAdult, it.isActive) } + } + + @Transactional(readOnly = true) + fun listCharacters(curationId: Long): List { + val curation = curationRepository.findById(curationId) + .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } + val mappings = mappingRepository.findByCurationWithCharacterOrderBySortOrderAsc(curation) + return mappings.map { it.chatCharacter } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index e7e8fb4..7e7deaa 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -31,6 +31,7 @@ class ChatCharacterController( private val bannerService: ChatCharacterBannerService, private val chatRoomService: ChatRoomService, private val characterCommentService: CharacterCommentService, + private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -85,8 +86,22 @@ class ChatCharacterController( ) } - // 큐레이션 섹션 (현재는 빈 리스트) - val curationSections = emptyList() + // 큐레이션 섹션 (활성화된 큐레이션 + 캐릭터) + val curationSections = curationQueryService.getActiveCurationsWithCharacters() + .map { agg -> + CurationSection( + characterCurationId = agg.curation.id!!, + title = agg.curation.title, + characters = agg.characters.map { + Character( + characterId = it.id!!, + name = it.name, + description = it.description, + imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}" + ) + } + ) + } // 응답 생성 ApiResponse.ok( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt new file mode 100644 index 0000000..021933e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCuration.kt @@ -0,0 +1,47 @@ +package kr.co.vividnext.sodalive.chat.character.curation + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.OneToMany + +@Entity +class CharacterCuration( + @Column(nullable = false) + var title: String, + + // 19금 여부 + @Column(nullable = false) + var isAdult: Boolean = false, + + // 활성화 여부 (소프트 삭제) + @Column(nullable = false) + var isActive: Boolean = true, + + // 정렬 순서 (낮을수록 먼저) + @Column(nullable = false) + var sortOrder: Int = 0 +) : BaseEntity() { + @OneToMany(mappedBy = "curation", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) + var characterMappings: MutableList = mutableListOf() +} + +@Entity +class CharacterCurationMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "curation_id") + var curation: CharacterCuration, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id") + var chatCharacter: ChatCharacter, + + // 정렬 순서 (낮을수록 먼저) + @Column(nullable = false) + var sortOrder: Int = 0 +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt new file mode 100644 index 0000000..31db343 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/CharacterCurationQueryService.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.chat.character.curation + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationMappingRepository +import kr.co.vividnext.sodalive.chat.character.curation.repository.CharacterCurationRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CharacterCurationQueryService( + private val curationRepository: CharacterCurationRepository, + private val mappingRepository: CharacterCurationMappingRepository +) { + data class CurationAgg( + val curation: CharacterCuration, + val characters: List + ) + + @Transactional(readOnly = true) + fun getActiveCurationsWithCharacters(): List { + val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc() + if (curations.isEmpty()) return emptyList() + + // 매핑 + 캐릭터를 한 번에 조회(ch.isActive = true 필터 적용)하여 N+1 해소 + val mappings = mappingRepository + .findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc(curations) + + val charactersByCurationId: Map> = mappings + .groupBy { it.curation.id!! } + .mapValues { (_, list) -> list.map { it.chatCharacter } } + + return curations.map { curation -> + val characters = charactersByCurationId[curation.id!!] ?: emptyList() + CurationAgg(curation, characters) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt new file mode 100644 index 0000000..db42281 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt @@ -0,0 +1,33 @@ +package kr.co.vividnext.sodalive.chat.character.curation.repository + +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationMapping +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 + +@Repository +interface CharacterCurationMappingRepository : JpaRepository { + fun findByCuration(curation: CharacterCuration): List + + @Query( + "select m from CharacterCurationMapping m " + + "join fetch m.chatCharacter ch " + + "where m.curation in :curations and ch.isActive = true " + + "order by m.curation.id asc, m.sortOrder asc" + ) + fun findByCurationInWithActiveCharacterOrderByCurationIdAscAndSortOrderAsc( + @Param("curations") curations: List + ): List + + @Query( + "select m from CharacterCurationMapping m " + + "join fetch m.chatCharacter ch " + + "where m.curation = :curation " + + "order by m.sortOrder asc" + ) + fun findByCurationWithCharacterOrderBySortOrderAsc( + @Param("curation") curation: CharacterCuration + ): List +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt new file mode 100644 index 0000000..ed658d1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt @@ -0,0 +1,15 @@ +package kr.co.vividnext.sodalive.chat.character.curation.repository + +import kr.co.vividnext.sodalive.chat.character.curation.CharacterCuration +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface CharacterCurationRepository : JpaRepository { + fun findByIsActiveTrueOrderBySortOrderAsc(): List + fun findAllByOrderBySortOrderAsc(): List + + @Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true") + fun findMaxSortOrder(): Int? +} From d26e0a89f6b6f08dff3b3a1d45c28e875b277a74 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 19:22:31 +0900 Subject: [PATCH 110/119] =?UTF-8?q?feat(admin-curation):=20=ED=81=90?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복 ID 제거 및 0 이하 ID 필터링 - 조회 단계에서 활성 캐릭터만 조회하여 검증 포함 - 존재하지 않거나 비활성인 캐릭터는 건너뛰고 나머지만 등록 - 기존 매핑 있는 캐릭터는 무시, 다음 정렬 순서(nextOrder)로 일괄 추가 --- .../CharacterCurationAdminController.kt | 8 +++- .../curation/CharacterCurationAdminDto.kt | 2 +- .../curation/CharacterCurationAdminService.kt | 44 +++++++++++++------ .../repository/ChatCharacterRepository.kt | 2 + 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt index aa6870f..f67002e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminController.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.admin.chat.character.curation import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException import org.springframework.beans.factory.annotation.Value import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.DeleteMapping @@ -60,7 +61,12 @@ class CharacterCurationAdminController( fun addCharacter( @PathVariable curationId: Long, @RequestBody request: CharacterCurationAddCharacterRequest - ) = ApiResponse.ok(service.addCharacter(curationId, request.characterId)) + ): ApiResponse { + val ids = request.characterIds.filter { it > 0 }.distinct() + if (ids.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다") + service.addCharacters(curationId, ids) + return ApiResponse.ok(true) + } @DeleteMapping("/{curationId}/characters/{characterId}") fun removeCharacter( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt index bb46b03..6266ebd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt @@ -18,7 +18,7 @@ data class CharacterCurationOrderUpdateRequest( ) data class CharacterCurationAddCharacterRequest( - val characterId: Long + val characterIds: List ) data class CharacterCurationReorderCharactersRequest( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt index 72b7690..df5e6d0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt @@ -60,26 +60,42 @@ class CharacterCurationAdminService( } @Transactional - fun addCharacter(curationId: Long, characterId: Long) { + fun addCharacters(curationId: Long, characterIds: List) { + if (characterIds.isEmpty()) throw SodaException("등록할 캐릭터 ID 리스트가 비어있습니다") + val curation = curationRepository.findById(curationId) .orElseThrow { SodaException("큐레이션을 찾을 수 없습니다: $curationId") } if (!curation.isActive) throw SodaException("비활성화된 큐레이션입니다: $curationId") - val character = characterRepository.findById(characterId) - .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } - if (!character.isActive) throw SodaException("비활성화된 캐릭터는 추가할 수 없습니다: $characterId") + val uniqueIds = characterIds.filter { it > 0 }.distinct() + if (uniqueIds.isEmpty()) throw SodaException("유효한 캐릭터 ID가 없습니다") - val existing = mappingRepository.findByCuration(curation) - .firstOrNull { it.chatCharacter.id == characterId } - if (existing != null) return // 이미 존재하면 무시 + // 활성 캐릭터만 조회 (조회 단계에서 검증 포함) + val characters = characterRepository.findByIdInAndIsActiveTrue(uniqueIds) + val characterMap = characters.associateBy { it.id!! } - val nextOrder = (mappingRepository.findByCuration(curation).maxOfOrNull { it.sortOrder } ?: 0) + 1 - val mapping = CharacterCurationMapping( - curation = curation, - chatCharacter = character, - sortOrder = nextOrder - ) - mappingRepository.save(mapping) + // 조회 결과에 존재하는 캐릭터만 유효 + val validIds = uniqueIds.filter { id -> characterMap.containsKey(id) } + + val existingMappings = mappingRepository.findByCuration(curation) + val existingCharacterIds = existingMappings.mapNotNull { it.chatCharacter.id }.toSet() + var nextOrder = (existingMappings.maxOfOrNull { it.sortOrder } ?: 0) + 1 + + val toSave = mutableListOf() + validIds.forEach { id -> + if (!existingCharacterIds.contains(id)) { + val character = characterMap[id] ?: return@forEach + toSave += CharacterCurationMapping( + curation = curation, + chatCharacter = character, + sortOrder = nextOrder++ + ) + } + } + + if (toSave.isNotEmpty()) { + mappingRepository.saveAll(toSave) + } } @Transactional 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 ede9fa5..d03ee4f 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 @@ -61,4 +61,6 @@ interface ChatCharacterRepository : JpaRepository { @Param("characterId") characterId: Long, pageable: Pageable ): List + + fun findByIdInAndIsActiveTrue(ids: List): List } From 550e4ac9ceb14b3c4e9abbd2d1c3d1c17097c00d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 28 Aug 2025 19:50:20 +0900 Subject: [PATCH 111/119] =?UTF-8?q?fix(character-main):=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EB=8C=80=ED=99=94=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20roomId=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20characterId=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/controller/ChatCharacterController.kt | 2 +- .../sodalive/chat/character/dto/CharacterHomeResponse.kt | 2 +- .../kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt | 2 ++ .../sodalive/chat/room/repository/ChatRoomRepository.kt | 3 ++- .../co/vividnext/sodalive/chat/room/service/ChatRoomService.kt | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 7e7deaa..6b780f0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -57,7 +57,7 @@ class ChatCharacterController( chatRoomService.listMyChatRooms(member, 0, 10) .map { room -> RecentCharacter( - roomId = room.chatRoomId, + characterId = room.characterId, name = room.title, imageUrl = room.imageUrl ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt index 745d2d2..b471315 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterHomeResponse.kt @@ -22,7 +22,7 @@ data class Character( ) data class RecentCharacter( - val roomId: Long, + val characterId: Long, val name: String, val imageUrl: String ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt index df80d89..95d9378 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/dto/ChatRoomDto.kt @@ -24,6 +24,7 @@ data class CreateChatRoomResponse( */ data class ChatRoomListItemDto( val chatRoomId: Long, + val characterId: Long, val title: String, val imageUrl: String, val opponentType: String, @@ -61,6 +62,7 @@ data class ChatMessagesPageResponse( data class ChatRoomListQueryDto( val chatRoomId: Long, + val characterId: Long, val title: String, val imagePath: String?, val characterType: CharacterType, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt index a5f3d6c..07d4a69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt @@ -39,6 +39,7 @@ interface ChatRoomRepository : JpaRepository { value = """ SELECT new kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto( r.id, + pc.character.id, r.title, pc.character.imagePath, pc.character.characterType, @@ -54,7 +55,7 @@ interface ChatRoomRepository : JpaRepository { AND pc.isActive = true AND r.isActive = true AND m.isActive = true - GROUP BY r.id, r.title, r.createdAt, pc.character.imagePath, pc.character.characterType + GROUP BY r.id, r.title, r.createdAt, pc.character.id, pc.character.imagePath, pc.character.characterType ORDER BY MAX(m.createdAt) DESC """ ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index ddf4279..9b24ba9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -270,6 +270,7 @@ class ChatRoomService( ChatRoomListItemDto( chatRoomId = q.chatRoomId, + characterId = q.characterId, title = q.title, imageUrl = imageUrl, opponentType = opponentType, From 034472defae157100520c0a97f52b6b46f886f5a Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 29 Aug 2025 01:38:49 +0900 Subject: [PATCH 112/119] =?UTF-8?q?fix(chat-character):=20DB=EC=97=90?= =?UTF-8?q?=EC=84=9C=20speechStyle=20type=EC=9D=84=20varchar=EC=97=90?= =?UTF-8?q?=EC=84=9C=20text=EB=A1=9C=EC=9D=98=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20@Column(columnDefinition=20=3D?= =?UTF-8?q?=20"TEXT")=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt | 1 + 1 file changed, 1 insertion(+) 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 75d8680..1e5e30a 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 @@ -37,6 +37,7 @@ class ChatCharacter( var speechPattern: String? = null, // 대화 스타일 + @Column(columnDefinition = "TEXT") var speechStyle: String? = null, // 외모 설명 From def6296d4d11e14b471afcc92be5d808bedc5fd6 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Sep 2025 11:02:54 +0900 Subject: [PATCH 113/119] =?UTF-8?q?fix(chat-character):=20=EC=BA=90?= =?UTF-8?q?=EB=A6=AD=ED=84=B0=20=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95=20AP?= =?UTF-8?q?I=20-=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/character/AdminChatCharacterController.kt | 12 ------------ 1 file changed, 12 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 ab05224..f248fa4 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 @@ -18,8 +18,6 @@ import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType import org.springframework.http.client.SimpleClientHttpRequestFactory -import org.springframework.retry.annotation.Backoff -import org.springframework.retry.annotation.Retryable import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -86,11 +84,6 @@ class AdminChatCharacterController( } @PostMapping("/register") - @Retryable( - value = [Exception::class], - maxAttempts = 3, - backoff = Backoff(delay = 1000) - ) fun registerCharacter( @RequestPart("image") image: MultipartFile, @RequestPart("request") requestString: String @@ -236,11 +229,6 @@ class AdminChatCharacterController( * @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우 */ @PutMapping("/update") - @Retryable( - value = [Exception::class], - maxAttempts = 3, - backoff = Backoff(delay = 1000) - ) fun updateCharacter( @RequestPart(value = "image", required = false) image: MultipartFile?, @RequestPart("request") requestString: String From 3a9128a894f0b73dc099b87989869d49cb12f7be Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Sep 2025 12:29:26 +0900 Subject: [PATCH 114/119] =?UTF-8?q?fix(character):=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A6=9D=EB=B6=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EA=B0=92?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EA=B0=80=EB=B3=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왜: 기존에는 추가 정보(memories, personalities, backgrounds, relationships) 수정 시 전체 삭제 후 재생성되어 변경 누락/DB 오버헤드가 발생함 - 무엇: - Memory/Personality/Background 값 필드(content/description/emotion)를 var로 전환해 in-place 업데이트 허용 - 서비스 레이어에 증분 업데이트 로직 적용 - 요청에 없는 항목만 제거, 기존 항목은 값만 갱신, 신규 키만 추가 - relationships는 personName+relationshipName 복합 키 매칭(keyOf)으로 필드만 갱신 - ChatCharacter 컬렉션에 orphanRemoval=true 설정하여 iterator.remove 시 고아 삭제 보장 - updateChatCharacterWithDetails에서 clear/add 제거 → 증분 업데이트 메서드 호출로 변경 - 효과: DELETE+INSERT 제거로 성능 개선, ID/createdAt 유지로 감사 추적 용이, 데이터 정합성 향상 --- .../sodalive/chat/character/ChatCharacter.kt | 8 +- .../chat/character/ChatCharacterBackground.kt | 2 +- .../chat/character/ChatCharacterMemory.kt | 4 +- .../character/ChatCharacterPersonality.kt | 2 +- .../character/service/ChatCharacterService.kt | 239 +++++++++++------- 5 files changed, 156 insertions(+), 99 deletions(-) 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 1e5e30a..96738f0 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 @@ -61,16 +61,16 @@ class ChatCharacter( ) : BaseEntity() { var imagePath: String? = null - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var memories: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var personalities: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var backgrounds: MutableList = mutableListOf() - @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var relationships: MutableList = mutableListOf() @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt index a3297fa..b1cd2c3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterBackground.kt @@ -18,7 +18,7 @@ class ChatCharacterBackground( // 배경 설명 @Column(columnDefinition = "TEXT", nullable = false) - val description: String, + var description: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_character_id") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt index 9ef9380..1f59eec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterMemory.kt @@ -18,10 +18,10 @@ class ChatCharacterMemory( // 기억 내용 @Column(columnDefinition = "TEXT", nullable = false) - val content: String, + var content: String, // 감정 - val emotion: String, + var emotion: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_character_id") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt index 647c234..3cfff76 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacterPersonality.kt @@ -18,7 +18,7 @@ class ChatCharacterPersonality( // 성격 특성 설명 @Column(columnDefinition = "TEXT", nullable = false) - val description: String, + var description: String, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "chat_character_id") 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 cdec44d..c052972 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 @@ -1,5 +1,8 @@ package kr.co.vividnext.sodalive.chat.character.service +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest +import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.chat.character.CharacterType @@ -222,6 +225,147 @@ class ChatCharacterService( } } + /** + * 기억(memories) 증분 업데이트 + */ + @Transactional + fun updateMemoriesForCharacter(chatCharacter: ChatCharacter, memories: List) { + val desiredByTitle = memories + .asSequence() + .distinctBy { it.title } + .associateBy { it.title } + + val iterator = chatCharacter.memories.iterator() + while (iterator.hasNext()) { + val current = iterator.next() + val desired = desiredByTitle[current.title] + if (desired == null) { + // 요청에 없는 항목은 제거 + iterator.remove() + } else { + // 값 필드만 in-place 업데이트 + if (current.content != desired.content) current.content = desired.content + if (current.emotion != desired.emotion) current.emotion = desired.emotion + } + } + + // 신규 추가 + val existingTitles = chatCharacter.memories.map { it.title }.toSet() + desiredByTitle.values + .filterNot { existingTitles.contains(it.title) } + .forEach { chatCharacter.addMemory(it.title, it.content, it.emotion) } + } + + /** + * 성격(personalities) 증분 업데이트 + */ + @Transactional + fun updatePersonalitiesForCharacter( + chatCharacter: ChatCharacter, + personalities: List + ) { + val desiredByTrait = personalities + .asSequence() + .distinctBy { it.trait } + .associateBy { it.trait } + + val iterator = chatCharacter.personalities.iterator() + while (iterator.hasNext()) { + val current = iterator.next() + val desired = desiredByTrait[current.trait] + if (desired == null) { + // 요청에 없는 항목은 제거 + iterator.remove() + } else { + // 값 필드만 in-place 업데이트 + if (current.description != desired.description) current.description = desired.description + } + } + + // 신규 추가 + val existingTraits = chatCharacter.personalities.map { it.trait }.toSet() + desiredByTrait.values + .filterNot { existingTraits.contains(it.trait) } + .forEach { chatCharacter.addPersonality(it.trait, it.description) } + } + + /** + * 배경(backgrounds) 증분 업데이트 + */ + @Transactional + fun updateBackgroundsForCharacter(chatCharacter: ChatCharacter, backgrounds: List) { + val desiredByTopic = backgrounds + .asSequence() + .distinctBy { it.topic } + .associateBy { it.topic } + + val iterator = chatCharacter.backgrounds.iterator() + while (iterator.hasNext()) { + val current = iterator.next() + val desired = desiredByTopic[current.topic] + if (desired == null) { + // 요청에 없는 항목은 제거 + iterator.remove() + } else { + // 값 필드만 in-place 업데이트 + if (current.description != desired.description) current.description = desired.description + } + } + + // 신규 추가 + val existingTopics = chatCharacter.backgrounds.map { it.topic }.toSet() + desiredByTopic.values + .filterNot { existingTopics.contains(it.topic) } + .forEach { chatCharacter.addBackground(it.topic, it.description) } + } + + /** + * 관계(relationships) 증분 업데이트 + */ + @Transactional + fun updateRelationshipsForCharacter( + chatCharacter: ChatCharacter, + relationships: List + ) { + fun keyOf(p: String, r: String) = "$" + "{" + p + "}" + "::" + "{" + r + "}" + + val desiredByKey = relationships + .asSequence() + .distinctBy { keyOf(it.personName, it.relationshipName) } + .associateBy { keyOf(it.personName, it.relationshipName) } + + val iterator = chatCharacter.relationships.iterator() + while (iterator.hasNext()) { + val current = iterator.next() + val key = keyOf(current.personName, current.relationshipName) + val desired = desiredByKey[key] + if (desired == null) { + iterator.remove() + } else { + if (current.description != desired.description) current.description = desired.description + if (current.importance != desired.importance) current.importance = desired.importance + if (current.relationshipType != desired.relationshipType) { + current.relationshipType = desired.relationshipType + } + if (current.currentStatus != desired.currentStatus) current.currentStatus = desired.currentStatus + } + } + + val existingKeys = chatCharacter.relationships.map { keyOf(it.personName, it.relationshipName) }.toSet() + desiredByKey.values + .filterNot { existingKeys.contains(keyOf(it.personName, it.relationshipName)) } + .forEach { rr -> + chatCharacter.addRelationship( + rr.personName, + rr.relationshipName, + rr.description, + rr.importance, + rr.relationshipType, + rr.currentStatus + ) + } + } + /** * 캐릭터 저장 */ @@ -230,14 +374,6 @@ class ChatCharacterService( return chatCharacterRepository.save(chatCharacter) } - /** - * UUID로 캐릭터 조회 - */ - @Transactional(readOnly = true) - fun findByCharacterUUID(characterUUID: String): ChatCharacter? { - return chatCharacterRepository.findByCharacterUUID(characterUUID) - } - /** * 이름으로 캐릭터 조회 */ @@ -246,14 +382,6 @@ class ChatCharacterService( return chatCharacterRepository.findByName(name) } - /** - * 모든 캐릭터 조회 - */ - @Transactional(readOnly = true) - fun findAll(): List { - return chatCharacterRepository.findAll() - } - /** * ID로 캐릭터 조회 */ @@ -331,57 +459,6 @@ class ChatCharacterService( return saveChatCharacter(chatCharacter) } - /** - * 캐릭터에 기억 추가 - */ - @Transactional - fun addMemoryToChatCharacter(chatCharacter: ChatCharacter, title: String, content: String, emotion: String) { - chatCharacter.addMemory(title, content, emotion) - saveChatCharacter(chatCharacter) - } - - /** - * 캐릭터에 성격 특성 추가 - */ - @Transactional - fun addPersonalityToChatCharacter(chatCharacter: ChatCharacter, trait: String, description: String) { - chatCharacter.addPersonality(trait, description) - saveChatCharacter(chatCharacter) - } - - /** - * 캐릭터에 배경 정보 추가 - */ - @Transactional - fun addBackgroundToChatCharacter(chatCharacter: ChatCharacter, topic: String, description: String) { - chatCharacter.addBackground(topic, description) - saveChatCharacter(chatCharacter) - } - - /** - * 캐릭터에 관계 추가 - */ - @Transactional - fun addRelationshipToChatCharacter( - chatCharacter: ChatCharacter, - personName: String, - relationshipName: String, - description: String, - importance: Int, - relationshipType: String, - currentStatus: String - ) { - chatCharacter.addRelationship( - personName, - relationshipName, - description, - importance, - relationshipType, - currentStatus - ) - saveChatCharacter(chatCharacter) - } - /** * 캐릭터 생성 시 기본 정보와 함께 추가 정보도 설정 */ @@ -464,7 +541,6 @@ class ChatCharacterService( * @param imagePath 이미지 경로 (null이면 이미지 변경 없음) * @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능) * @return 수정된 ChatCharacter 객체 - * @throws SodaException 캐릭터를 찾을 수 없는 경우 */ @Transactional fun updateChatCharacterWithDetails( @@ -526,38 +602,19 @@ class ChatCharacterService( // 추가 정보 설정 - 변경된 데이터만 업데이트 if (request.memories != null) { - chatCharacter.memories.clear() - request.memories.forEach { memory -> - chatCharacter.addMemory(memory.title, memory.content, memory.emotion) - } + updateMemoriesForCharacter(chatCharacter, request.memories) } if (request.personalities != null) { - chatCharacter.personalities.clear() - request.personalities.forEach { personality -> - chatCharacter.addPersonality(personality.trait, personality.description) - } + updatePersonalitiesForCharacter(chatCharacter, request.personalities) } if (request.backgrounds != null) { - chatCharacter.backgrounds.clear() - request.backgrounds.forEach { background -> - chatCharacter.addBackground(background.topic, background.description) - } + updateBackgroundsForCharacter(chatCharacter, request.backgrounds) } if (request.relationships != null) { - chatCharacter.relationships.clear() - request.relationships.forEach { rr -> - chatCharacter.addRelationship( - rr.personName, - rr.relationshipName, - rr.description, - rr.importance, - rr.relationshipType, - rr.currentStatus - ) - } + updateRelationshipsForCharacter(chatCharacter, request.relationships) } return saveChatCharacter(chatCharacter) From 2f55303d16fb18c41318bf5855c7269e20439edf Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Sep 2025 14:06:01 +0900 Subject: [PATCH 115/119] =?UTF-8?q?feat(admin-curation):=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=ED=99=9C=EC=84=B1=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=20DB=20=EC=A7=91=EA=B3=84=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비활성(삭제) 큐레이션을 목록에서 제외: findByIsActiveTrueOrderBySortOrderAsc 사용 - 리스트 항목에 characterCount 추가 및 DB GROUP BY + COUNT로 직접 집계 - CharacterCurationMappingRepository: 집계용 프로젝션(CharacterCountPerCuration)과 countActiveCharactersByCurations 쿼리 추가 - CharacterCurationAdminService: listAll에서 집계 결과를 활용해 characterCount 매핑 (대량 엔티티 로딩 제거) - CharacterCurationRepository: findMaxSortOrder 쿼리로 신규 등록 정렬 순서 계산에 활용 - 컨트롤러: 캐릭터 리스트 응답 DTO(CharacterCurationCharacterItemResponse) 사용, 이미지 URL은 CloudFront host + imagePath로 조립 --- .../curation/CharacterCurationAdminDto.kt | 3 ++- .../curation/CharacterCurationAdminService.kt | 18 ++++++++++++++++-- .../CharacterCurationMappingRepository.kt | 15 +++++++++++++++ .../repository/CharacterCurationRepository.kt | 1 - 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt index 6266ebd..a5d6a58 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminDto.kt @@ -29,7 +29,8 @@ data class CharacterCurationListItemResponse( val id: Long, val title: String, val isAdult: Boolean, - val isActive: Boolean + val isActive: Boolean, + val characterCount: Int ) // 관리자 큐레이션 상세 - 캐릭터 리스트 항목 응답 DTO diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt index df5e6d0..77da8f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/curation/CharacterCurationAdminService.kt @@ -125,8 +125,22 @@ class CharacterCurationAdminService( @Transactional(readOnly = true) fun listAll(): List { - return curationRepository.findAllByOrderBySortOrderAsc() - .map { CharacterCurationListItemResponse(it.id!!, it.title, it.isAdult, it.isActive) } + val curations = curationRepository.findByIsActiveTrueOrderBySortOrderAsc() + if (curations.isEmpty()) return emptyList() + + // DB 집계로 활성 캐릭터 수 카운트 + val counts = mappingRepository.countActiveCharactersByCurations(curations) + val countByCurationId: Map = counts.associate { it.curationId to it.count.toInt() } + + return curations.map { curation -> + CharacterCurationListItemResponse( + id = curation.id!!, + title = curation.title, + isAdult = curation.isAdult, + isActive = curation.isActive, + characterCount = countByCurationId[curation.id!!] ?: 0 + ) + } } @Transactional(readOnly = true) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt index db42281..0f4a027 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationMappingRepository.kt @@ -30,4 +30,19 @@ interface CharacterCurationMappingRepository : JpaRepository + + interface CharacterCountPerCuration { + val curationId: Long + val count: Long + } + + @Query( + "select m.curation.id as curationId, count(m.id) as count " + + "from CharacterCurationMapping m join m.chatCharacter ch " + + "where m.curation in :curations and ch.isActive = true " + + "group by m.curation.id" + ) + fun countActiveCharactersByCurations( + @Param("curations") curations: List + ): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt index ed658d1..a8d71f0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/curation/repository/CharacterCurationRepository.kt @@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository @Repository interface CharacterCurationRepository : JpaRepository { fun findByIsActiveTrueOrderBySortOrderAsc(): List - fun findAllByOrderBySortOrderAsc(): List @Query("SELECT MAX(c.sortOrder) FROM CharacterCuration c WHERE c.isActive = true") fun findMaxSortOrder(): Int? From ad69dad7253ff56c1c0035ac7a04859558ce47cd Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Sep 2025 16:33:53 +0900 Subject: [PATCH 116/119] =?UTF-8?q?fix(character-image):=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20ownedCount=EC=97=90?= =?UTF-8?q?=20=ED=94=84=EB=A1=9C=ED=95=84(+1)=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로필 이미지는 무료로 항상 열람 가능하므로 보유 개수(ownedCount)에도 프로필 1장을 포함하도록 수정했습니다. 이를 통해 전체 개수(totalCount)와 보유 개수 산정 기준이 일관되게 맞춰집니다. --- .../sodalive/chat/character/image/CharacterImageController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt index 057ca9c..8744e26 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/image/CharacterImageController.kt @@ -43,7 +43,8 @@ class CharacterImageController( // 전체 활성 이미지 수(프로필 제외) 파악을 위해 최소 페이지 조회 val totalActiveElements = imageService.pageActiveByCharacter(characterId, PageRequest.of(0, 1)).totalElements - val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + // 프로필 이미지는 무료로 볼 수 있으므로 보유 개수에도 +1 반영 + val ownedCount = imageService.countOwnedActiveByCharacterForMember(characterId, member.id!!) + 1 val totalCount = totalActiveElements + 1 // 프로필 포함 From a9d1b9f4a62d8899676b497aeb7c93915a663760 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 5 Sep 2025 16:55:50 +0900 Subject: [PATCH 117/119] =?UTF-8?q?fix(character):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=97=90=20MBTI=C2=B7=EC=84=B1=EB=B3=84=C2=B7?= =?UTF-8?q?=EB=82=98=EC=9D=B4=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterDetailResponse에 gender, age 필드 추가 - ChatCharacterController에서 gender, age 매핑 - 기존 엔티티(ChatCharacter)의 gender/age 활용 --- .../chat/character/controller/ChatCharacterController.kt | 2 ++ .../sodalive/chat/character/dto/CharacterDetailResponse.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index 6b780f0..930a9a4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -175,6 +175,8 @@ class ChatCharacterController( name = character.name, description = character.description, mbti = character.mbti, + gender = character.gender, + age = character.age, imageUrl = "$imageHost/${character.imagePath ?: "profile/default-profile.png"}", personalities = personality, backgrounds = background, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt index 64e3632..1f5c6c5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/dto/CharacterDetailResponse.kt @@ -8,6 +8,8 @@ data class CharacterDetailResponse( val name: String, val description: String, val mbti: String?, + val gender: String?, + val age: Int?, val imageUrl: String, val personalities: CharacterPersonalityResponse?, val backgrounds: CharacterBackgroundResponse?, From fd83abb46c738e8f15af2d3436ebdb91641f3f76 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 9 Sep 2025 22:42:14 +0900 Subject: [PATCH 118/119] =?UTF-8?q?feat(chat):=20=EA=B8=80=EB=A1=9C?= =?UTF-8?q?=EB=B2=8C/=EB=B0=A9=20=EC=BF=BC=ED=84=B0=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=20=EA=B0=9C=ED=8E=B8,=20=EA=B2=B0=EC=A0=9C/=EC=A1=B0=ED=9A=8C/?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8/=EC=9D=B4=EA=B4=80=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 글로벌: 무료 40, UTC 20:00 lazy refill(유료 제거) 방: 무료 10, 무료 0 순간 now+6h, 경과 시 lazy refill(무료=10, next=null) 전송: 유료 우선, 무료 사용 시 글로벌/룸 동시 차감, 조건 불충족 예외 API: 방 쿼터 조회/구매 추가(구매 시 30캔, UseCan에 roomId:characterId 기록) next 계산: enter/send에서 경계 케이스 처리(max(room, global)) 대화 초기화: 유료 쿼터 새 방으로 이관 --- .../sodalive/can/payment/CanPaymentService.kt | 10 +- .../co/vividnext/sodalive/can/use/UseCan.kt | 6 +- .../chat/quota/ChatQuotaController.kt | 8 +- .../sodalive/chat/quota/ChatQuotaService.kt | 82 ++++----- .../sodalive/chat/quota/room/ChatRoomQuota.kt | 19 ++ .../quota/room/ChatRoomQuotaController.kt | 139 ++++++++++++++ .../quota/room/ChatRoomQuotaRepository.kt | 16 ++ .../chat/quota/room/ChatRoomQuotaService.kt | 172 ++++++++++++++++++ .../chat/room/service/ChatRoomService.kt | 93 ++++++++-- 9 files changed, 470 insertions(+), 75 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt index ce57b14..b34f886 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/payment/CanPaymentService.kt @@ -38,6 +38,8 @@ class CanPaymentService( memberId: Long, needCan: Int, canUsage: CanUsage, + chatRoomId: Long? = null, + characterId: Long? = null, isSecret: Boolean = false, liveRoom: LiveRoom? = null, order: Order? = null, @@ -110,12 +112,14 @@ class CanPaymentService( recipientId = liveRoom.member!!.id!! useCan.room = liveRoom useCan.member = member - } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE) { - // 채팅 쿼터 구매는 수신자 개념 없이 본인에게 귀속 + } else if (canUsage == CanUsage.CHAT_QUOTA_PURCHASE && chatRoomId != null && characterId != null) { useCan.member = member + useCan.chatRoomId = chatRoomId + useCan.characterId = characterId } else if (canUsage == CanUsage.CHAT_ROOM_RESET) { - // 채팅방 초기화 결제: 별도 구분. 수신자 없이 본인 귀속 useCan.member = member + useCan.chatRoomId = chatRoomId + useCan.characterId = characterId } else { throw SodaException("잘못된 요청입니다.") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt index 3d0fc46..dfb0b2b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt @@ -30,7 +30,11 @@ data class UseCan( var isRefund: Boolean = false, - val isSecret: Boolean = false + val isSecret: Boolean = false, + + // 채팅 연동을 위한 식별자 (옵션) + var chatRoomId: Long? = null, + var characterId: Long? = null ) : BaseEntity() { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt index fa0640e..c82a281 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaController.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.chat.quota +import kr.co.vividnext.sodalive.can.payment.CanPaymentService import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException @@ -15,7 +16,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/chat/quota") class ChatQuotaController( private val chatQuotaService: ChatQuotaService, - private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService + private val canPaymentService: CanPaymentService ) { data class ChatQuotaStatusResponse( @@ -55,10 +56,7 @@ class ChatQuotaController( container = request.container ) - // 유료 횟수 적립 (기본 40) - val add = 40 - chatQuotaService.purchase(member.id!!, add) - + // 글로벌 유료 개념 제거됨: 구매 성공 시에도 글로벌 쿼터 증액 없음 val s = chatQuotaService.getStatus(member.id!!) ApiResponse.ok(ChatQuotaStatusResponse(s.totalRemaining, s.nextRechargeAtEpochMillis)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt index d62c822..b3685ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/ChatQuotaService.kt @@ -1,18 +1,18 @@ package kr.co.vividnext.sodalive.chat.quota -import kr.co.vividnext.sodalive.common.SodaException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId +import java.time.ZoneOffset @Service class ChatQuotaService( private val repo: ChatQuotaRepository ) { companion object { - private const val FREE_BUCKET = 10 - private const val RECHARGE_HOURS = 1L + private const val FREE_BUCKET = 40 } data class QuotaStatus( @@ -20,63 +20,43 @@ class ChatQuotaService( val nextRechargeAtEpochMillis: Long? ) - @Transactional - fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { - val now = LocalDateTime.now() - val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) - if (quota.remainingFree == 0 && quota.nextRechargeAt != null && !now.isBefore(quota.nextRechargeAt)) { - quota.remainingFree = FREE_BUCKET - quota.nextRechargeAt = null - } - val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() - return QuotaStatus(totalRemaining = quota.total(), nextRechargeAtEpochMillis = epoch) + private fun nextUtc20LocalDateTime(now: Instant = Instant.now()): LocalDateTime { + val nowUtc = LocalDateTime.ofInstant(now, ZoneOffset.UTC) + val today20 = nowUtc.withHour(20).withMinute(0).withSecond(0).withNano(0) + val target = if (nowUtc.isBefore(today20)) today20 else today20.plusDays(1) + // 저장은 시스템 기본 타임존의 LocalDateTime으로 보관 + return LocalDateTime.ofInstant(target.toInstant(ZoneOffset.UTC), ZoneId.systemDefault()) } @Transactional - fun consumeOne(memberId: Long) { - val now = LocalDateTime.now() + fun applyRefillOnEnterAndGetStatus(memberId: Long): QuotaStatus { + val now = Instant.now() val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) - - when { - quota.remainingFree > 0 -> { - quota.remainingFree -= 1 - if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) - } - } - - quota.remainingPaid > 0 -> { - quota.remainingPaid -= 1 - if (quota.remainingFree + quota.remainingPaid == 0 && quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) - } - } - - else -> { - if (quota.nextRechargeAt == null) { - quota.nextRechargeAt = now.plusMinutes(RECHARGE_HOURS) - } - throw SodaException("채팅 가능 횟수가 모두 소진되었습니다. 다음 무료 충전 이후 이용해주세요.") - } + // Lazy refill: nextRechargeAt이 없거나 현재를 지났다면 무료 40 회복 + val nextRecharge = nextUtc20LocalDateTime(now) + if (quota.nextRechargeAt == null || !LocalDateTime.now().isBefore(quota.nextRechargeAt)) { + quota.remainingFree = FREE_BUCKET } + // 다음 UTC20 기준 시간으로 항상 갱신 + quota.nextRechargeAt = nextRecharge + + val epoch = quota.nextRechargeAt?.atZone(ZoneId.systemDefault())?.toInstant()?.toEpochMilli() + // 글로벌은 유료 개념 제거: totalRemaining은 remainingFree만 사용 + return QuotaStatus(totalRemaining = quota.remainingFree, nextRechargeAtEpochMillis = epoch) + } + + @Transactional + fun consumeOneFree(memberId: Long) { + val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) + if (quota.remainingFree <= 0) { + // 소비 불가: 호출자는 상태 조회로 남은 시간을 판단 + throw IllegalStateException("No global free quota") + } + quota.remainingFree -= 1 } @Transactional fun getStatus(memberId: Long): QuotaStatus { return applyRefillOnEnterAndGetStatus(memberId) } - - @Transactional - fun purchase(memberId: Long, addPaid: Int) { - val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) - quota.remainingPaid += addPaid - quota.nextRechargeAt = null - } - - @Transactional - fun resetFreeToDefault(memberId: Long) { - val quota = repo.findForUpdate(memberId) ?: repo.save(ChatQuota(memberId = memberId)) - quota.remainingFree = FREE_BUCKET - quota.nextRechargeAt = null - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt new file mode 100644 index 0000000..ec5687e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuota.kt @@ -0,0 +1,19 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.Version + +@Entity +@Table(name = "chat_room_quota") +class ChatRoomQuota( + val memberId: Long, + val chatRoomId: Long, + val characterId: Long, + var remainingFree: Int = 10, + var remainingPaid: Int = 0, + var nextRechargeAt: Long? = null, + @Version + var version: Long? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt new file mode 100644 index 0000000..3064bc1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaController.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import kr.co.vividnext.sodalive.chat.quota.ChatQuotaService +import kr.co.vividnext.sodalive.chat.room.ParticipantType +import kr.co.vividnext.sodalive.chat.room.repository.ChatParticipantRepository +import kr.co.vividnext.sodalive.chat.room.repository.ChatRoomRepository +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +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.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/chat/rooms") +class ChatRoomQuotaController( + private val chatRoomRepository: ChatRoomRepository, + private val participantRepository: ChatParticipantRepository, + private val chatRoomQuotaService: ChatRoomQuotaService, + private val chatQuotaService: ChatQuotaService +) { + + data class PurchaseRoomQuotaRequest( + val container: String + ) + + data class PurchaseRoomQuotaResponse( + val totalRemaining: Int, + val nextRechargeAtEpoch: Long?, + val remainingFree: Int, + val remainingPaid: Int + ) + + data class RoomQuotaStatusResponse( + val totalRemaining: Int, + val nextRechargeAtEpoch: Long? + ) + + /** + * 채팅방 유료 쿼터 구매 API + * - 참여 여부 검증(내가 USER로 참여 중인 활성 방) + * - 30캔 결제 (UseCan에 chatRoomId:characterId 기록) + * - 방 유료 쿼터 40 충전 + */ + @PostMapping("/{chatRoomId}/quota/purchase") + fun purchaseRoomQuota( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long, + @RequestBody req: PurchaseRoomQuotaRequest + ): ApiResponse = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + if (req.container.isBlank()) throw SodaException("container를 확인해주세요.") + + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") + + // 내 참여 여부 확인 + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + + // 캐릭터 참여자 확인(유효한 AI 캐릭터 방인지 체크 및 characterId 기본값 보조) + val characterParticipant = participantRepository + .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + + val character = characterParticipant.character + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + + val characterId = character.id + ?: throw SodaException("잘못된 요청입니다. 캐릭터 정보를 확인해주세요.") + + // 서비스에서 결제 포함하여 처리 + val status = chatRoomQuotaService.purchase( + memberId = member.id!!, + chatRoomId = chatRoomId, + characterId = characterId, + addPaid = 40, + container = req.container + ) + + ApiResponse.ok( + PurchaseRoomQuotaResponse( + totalRemaining = status.totalRemaining, + nextRechargeAtEpoch = status.nextRechargeAtEpochMillis, + remainingFree = status.remainingFree, + remainingPaid = status.remainingPaid + ) + ) + } + + @GetMapping("/{chatRoomId}/quota/me") + fun getMyRoomQuota( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, + @PathVariable chatRoomId: Long + ): ApiResponse = run { + if (member == null) throw SodaException("로그인 정보를 확인해주세요.") + if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") + + val room = chatRoomRepository.findByIdAndIsActiveTrue(chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") + // 내 참여 여부 확인 + participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member) + ?: throw SodaException("잘못된 접근입니다") + // 캐릭터 확인 + val characterParticipant = participantRepository + .findByChatRoomAndParticipantTypeAndIsActiveTrue(room, ParticipantType.CHARACTER) + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + val character = characterParticipant.character + ?: throw SodaException("AI 캐릭터 채팅방이 아닙니다.") + + // 글로벌 Lazy refill + val globalStatus = chatQuotaService.getStatus(member.id!!) + + // 룸 Lazy refill 상태 + val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus( + memberId = member.id!!, + chatRoomId = chatRoomId, + characterId = character.id!!, + globalFree = globalStatus.totalRemaining + ) + + val next: Long? = when { + roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis + globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis + else -> null + } + ApiResponse.ok( + RoomQuotaStatusResponse( + totalRemaining = roomStatus.totalRemaining, + nextRechargeAtEpoch = next + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt new file mode 100644 index 0000000..962d8a2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaRepository.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import javax.persistence.LockModeType + +interface ChatRoomQuotaRepository : JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select q from ChatRoomQuota q where q.memberId = :memberId and q.chatRoomId = :chatRoomId") + fun findForUpdate( + @Param("memberId") memberId: Long, + @Param("chatRoomId") chatRoomId: Long + ): ChatRoomQuota? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt new file mode 100644 index 0000000..db24ea6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/quota/room/ChatRoomQuotaService.kt @@ -0,0 +1,172 @@ +package kr.co.vividnext.sodalive.chat.quota.room + +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.common.SodaException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Duration +import java.time.Instant + +@Service +class ChatRoomQuotaService( + private val repo: ChatRoomQuotaRepository, + private val canPaymentService: CanPaymentService +) { + data class RoomQuotaStatus( + val totalRemaining: Int, + val nextRechargeAtEpochMillis: Long?, + val remainingFree: Int, + val remainingPaid: Int + ) + + private fun calculateAvailableForRoom(globalFree: Int, roomFree: Int, roomPaid: Int): Int { + // 유료가 있으면 글로벌 상관 없이 (유료 + 무료동시가능수)로 계산 + // 무료만 있는 경우에는 글로벌과 룸 Free의 교집합으로 사용 가능 횟수 계산 + val freeUsable = minOf(globalFree, roomFree) + return roomPaid + freeUsable + } + + @Transactional + fun applyRefillOnEnterAndGetStatus( + memberId: Long, + chatRoomId: Long, + characterId: Long, + globalFree: Int + ): RoomQuotaStatus { + val now = Instant.now() + val nowMillis = now.toEpochMilli() + val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save( + ChatRoomQuota( + memberId = memberId, + chatRoomId = chatRoomId, + characterId = characterId, + remainingFree = 10, + remainingPaid = 0, + nextRechargeAt = null + ) + ) + // Lazy refill: nextRechargeAt이 현재를 지났으면 무료 10으로 리셋하고 next=null + if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { + quota.remainingFree = 10 + quota.nextRechargeAt = null + } + + val total = calculateAvailableForRoom( + globalFree = globalFree, + roomFree = quota.remainingFree, + roomPaid = quota.remainingPaid + ) + return RoomQuotaStatus( + totalRemaining = total, + nextRechargeAtEpochMillis = quota.nextRechargeAt, + remainingFree = quota.remainingFree, + remainingPaid = quota.remainingPaid + ) + } + + @Transactional + fun consumeOneForSend( + memberId: Long, + chatRoomId: Long, + globalFreeProvider: () -> Int, + consumeGlobalFree: () -> Unit + ): RoomQuotaStatus { + val now = Instant.now() + val nowMillis = now.toEpochMilli() + val quota = repo.findForUpdate(memberId, chatRoomId) + ?: throw SodaException("채팅방을 찾을 수 없습니다.") + + // 충전 시간이 지났다면 무료 10으로 리셋하고 next=null + if (quota.nextRechargeAt != null && quota.nextRechargeAt!! <= nowMillis) { + quota.remainingFree = 10 + quota.nextRechargeAt = null + } + + // 1) 유료 우선 사용: 글로벌에 영향 없음 + if (quota.remainingPaid > 0) { + quota.remainingPaid -= 1 + val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid) + return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid) + } + + // 2) 무료 사용: 글로벌과 룸 동시에 조건 충족 필요 + val globalFree = globalFreeProvider() + if (globalFree <= 0) { + // 전송 차단: 글로벌 무료가 0이며 유료도 0 → 전송 불가 + throw SodaException("무료 쿼터가 소진되었습니다. 글로벌 무료 충전 이후 이용해 주세요.") + } + if (quota.remainingFree <= 0) { + // 전송 차단: 룸 무료가 0이며 유료도 0 → 전송 불가 + val waitMillis = quota.nextRechargeAt + if (waitMillis != null && waitMillis > nowMillis) { + throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 무료 충전 이후 이용해 주세요.") + } else { + throw SodaException("채팅방 무료 쿼터가 소진되었습니다. 잠시 후 다시 시도해 주세요.") + } + } + + // 둘 다 가능 → 차감 + consumeGlobalFree() + quota.remainingFree -= 1 + if (quota.remainingFree == 0) { + // 무료가 0이 되는 순간 nextRechargeAt = 현재 + 6시간 + quota.nextRechargeAt = now.plus(Duration.ofHours(6)).toEpochMilli() + } + val total = calculateAvailableForRoom(globalFreeProvider(), quota.remainingFree, quota.remainingPaid) + return RoomQuotaStatus(total, quota.nextRechargeAt, quota.remainingFree, quota.remainingPaid) + } + + @Transactional + fun purchase( + memberId: Long, + chatRoomId: Long, + characterId: Long, + addPaid: Int = 40, + container: String + ): RoomQuotaStatus { + // 요구사항: 30캔 결제 및 UseCan에 방/캐릭터 기록 + canPaymentService.spendCan( + memberId = memberId, + needCan = 30, + canUsage = CanUsage.CHAT_QUOTA_PURCHASE, + chatRoomId = chatRoomId, + characterId = characterId, + container = container + ) + + val quota = repo.findForUpdate(memberId, chatRoomId) ?: repo.save( + ChatRoomQuota( + memberId = memberId, + chatRoomId = chatRoomId, + characterId = characterId + ) + ) + quota.remainingPaid += addPaid + quota.nextRechargeAt = null + + val total = quota.remainingPaid + quota.remainingFree + return RoomQuotaStatus( + totalRemaining = total, + nextRechargeAtEpochMillis = quota.nextRechargeAt, + remainingFree = quota.remainingFree, + remainingPaid = quota.remainingPaid + ) + } + + @Transactional + fun transferPaid(memberId: Long, fromChatRoomId: Long, toChatRoomId: Long, toCharacterId: Long) { + val from = repo.findForUpdate(memberId, fromChatRoomId) ?: return + if (from.remainingPaid <= 0) return + val to = repo.findForUpdate(memberId, toChatRoomId) ?: repo.save( + ChatRoomQuota( + memberId = memberId, + chatRoomId = toChatRoomId, + characterId = toCharacterId + ) + ) + to.remainingPaid += from.remainingPaid + from.remainingPaid = 0 + // 유료 이관은 룸 무료 충전 시간에 영향을 주지 않음 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 9b24ba9..1835fe5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.chat.character.image.CharacterImage import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.quota.room.ChatRoomQuotaService import kr.co.vividnext.sodalive.chat.room.ChatMessage import kr.co.vividnext.sodalive.chat.room.ChatMessageType import kr.co.vividnext.sodalive.chat.room.ChatParticipant @@ -52,6 +53,7 @@ class ChatRoomService( private val canPaymentService: kr.co.vividnext.sodalive.can.payment.CanPaymentService, private val imageCloudFront: kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront, private val chatQuotaService: kr.co.vividnext.sodalive.chat.quota.ChatQuotaService, + private val chatRoomQuotaService: ChatRoomQuotaService, @Value("\${weraser.api-key}") private val apiKey: String, @@ -376,8 +378,15 @@ class ChatRoomService( val messagesAsc = fetched.sortedBy { it.createdAt } val items = messagesAsc.map { toChatMessageItemDto(it, member) } - // 입장 시 Lazy refill 적용 후 상태 반환 - val quotaStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) + // 5-1) 글로벌 쿼터 Lazy refill + val globalStatus = chatQuotaService.applyRefillOnEnterAndGetStatus(member.id!!) + // 5-2) 룸 쿼터 Lazy refill + 상태 + val roomStatus = chatRoomQuotaService.applyRefillOnEnterAndGetStatus( + memberId = member.id!!, + chatRoomId = effectiveRoom.id!!, + characterId = character.id!!, + globalFree = globalStatus.totalRemaining + ) // 선택적 캐릭터 이미지 서명 URL 생성 처리 // 요구사항: baseRoom이 조건 불만족으로 동일 캐릭터의 내 활성 방으로 라우팅된 경우(bg 이미지 요청 무시)에는 null로 처리 @@ -419,13 +428,42 @@ class ChatRoomService( } } + // 권고안에 따른 next 계산 + val nextForEnter: Long? = when { + // roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next + roomStatus.remainingPaid == 0 && roomStatus.remainingFree > 0 && globalStatus.totalRemaining <= 0 -> + globalStatus.nextRechargeAtEpochMillis + // roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext + roomStatus.remainingPaid == 0 && roomStatus.remainingFree == 0 -> { + val roomNext = roomStatus.nextRechargeAtEpochMillis + val globalNext = globalStatus.nextRechargeAtEpochMillis + if (globalStatus.totalRemaining <= 0) { + if (roomNext == null) { + globalNext + } else if (globalNext == null) { + roomNext + } else { + maxOf( + roomNext, + globalNext + ) + } + } else { + roomNext + } + } + // 그 외 기존 규칙: room total==0 → room next, else if global<=0 → global next, else null + roomStatus.totalRemaining == 0 -> roomStatus.nextRechargeAtEpochMillis + globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis + else -> null + } return ChatRoomEnterResponse( roomId = effectiveRoom.id!!, character = characterDto, messages = items, hasMoreMessages = hasMore, - totalRemaining = quotaStatus.totalRemaining, - nextRechargeAtEpoch = quotaStatus.nextRechargeAtEpochMillis, + totalRemaining = roomStatus.totalRemaining, + nextRechargeAtEpoch = nextForEnter, bgImageUrl = signedUrl ) } @@ -602,8 +640,13 @@ class ChatRoomService( val sessionId = room.sessionId val characterUUID = character.characterUUID - // 5) 쿼터 확인 및 차감 - chatQuotaService.consumeOne(member.id!!) + // 5) 쿼터 확인 및 차감 (유료 우선, 무료 사용 시 글로벌과 룸 동시 차감) + val roomQuotaAfterConsume = chatRoomQuotaService.consumeOneForSend( + memberId = member.id!!, + chatRoomId = room.id!!, + globalFreeProvider = { chatQuotaService.getStatus(member.id!!).totalRemaining }, + consumeGlobalFree = { chatQuotaService.consumeOneFree(member.id!!) } + ) // 6) 외부 API 호출 (최대 3회 재시도) val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId) @@ -646,7 +689,21 @@ class ChatRoomService( hasAccess = true ) - val status = chatQuotaService.getStatus(member.id!!) + // 발송 후 최신 잔여 수량 및 next 계산 규칙 적용 + val statusTotalRemaining = roomQuotaAfterConsume.totalRemaining + val globalAfter = chatQuotaService.getStatus(member.id!!) + val statusNextRechargeAt: Long? = when { + // totalRemaining==0이고 (global<=0) → max(roomNext, globalNext) + statusTotalRemaining == 0 && globalAfter.totalRemaining <= 0 -> { + val roomNext = roomQuotaAfterConsume.nextRechargeAtEpochMillis + val globalNext = globalAfter.nextRechargeAtEpochMillis + if (roomNext == null) globalNext else if (globalNext == null) roomNext else maxOf(roomNext, globalNext) + } + + statusTotalRemaining == 0 -> roomQuotaAfterConsume.nextRechargeAtEpochMillis + globalAfter.totalRemaining <= 0 -> globalAfter.nextRechargeAtEpochMillis + else -> null + } // 8) 트리거 매칭 → 이미지 메시지 추가 저장(있을 경우) val matchedImage = findTriggeredCharacterImage(character.id!!, characterReply) @@ -676,15 +733,15 @@ class ChatRoomService( val imageDto = toChatMessageItemDto(imageMsg, member) return SendChatMessageResponse( messages = listOf(textDto, imageDto), - totalRemaining = status.totalRemaining, - nextRechargeAtEpoch = status.nextRechargeAtEpochMillis + totalRemaining = statusTotalRemaining, + nextRechargeAtEpoch = statusNextRechargeAt ) } return SendChatMessageResponse( messages = listOf(textDto), - totalRemaining = status.totalRemaining, - nextRechargeAtEpoch = status.nextRechargeAtEpochMillis + totalRemaining = statusTotalRemaining, + nextRechargeAtEpoch = statusNextRechargeAt ) } @@ -846,6 +903,8 @@ class ChatRoomService( memberId = member.id!!, needCan = 30, canUsage = CanUsage.CHAT_ROOM_RESET, + chatRoomId = chatRoomId, + characterId = character.id!!, container = container ) @@ -855,10 +914,14 @@ class ChatRoomService( // 4) 동일한 캐릭터와 새로운 채팅방 생성 val created = createOrGetChatRoom(member, character.id!!) - // 5) 신규 채팅방 생성 성공 시 무료 채팅 횟수 10으로 설정 - chatQuotaService.resetFreeToDefault(member.id!!) - - // 6) 생성된 채팅방 데이터 반환 + // 5) 신규 채팅방 생성 성공 시: 기존 방의 유료 쿼터를 새 방으로 이관 + chatRoomQuotaService.transferPaid( + memberId = member.id!!, + fromChatRoomId = chatRoomId, + toChatRoomId = created.chatRoomId, + toCharacterId = character.id!! + ) + // 글로벌 무료 쿼터는 UTC 20:00 기준 lazy 충전이므로 별도의 초기화 불필요 return created } } From 3782062f4a5e050fa52d984cbafe16f50563e9d2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 10 Sep 2025 13:31:27 +0900 Subject: [PATCH 119/119] =?UTF-8?q?fix(chat-room):=20=EC=9E=85=EC=9E=A5/?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20next=20=EA=B3=84=EC=82=B0=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20=EB=B0=8F=20=EC=B1=84=ED=8C=85=20=EA=B0=80=EB=8A=A5?= =?UTF-8?q?=20=EC=8B=9C=20next=3Dnull=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter: roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next roomPaid==0 && roomFree==0 → (global<=0) ? max(roomNext, globalNext) : roomNext 채팅 가능(totalRemaining>0)인 경우 next=null 반환(유료>0 포함) send: totalRemaining==0 && global<=0 → max(roomNext, globalNext) 채팅 가능(totalRemaining>0)인 경우 next=null 반환 --- .../sodalive/chat/room/service/ChatRoomService.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt index 1835fe5..abd21c4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/room/service/ChatRoomService.kt @@ -428,8 +428,10 @@ class ChatRoomService( } } - // 권고안에 따른 next 계산 + // 권고안 + 이슈 보정: 채팅 가능(totalRemaining>0)인 경우 next=null val nextForEnter: Long? = when { + // 채팅 가능: 유료>0 또는 무료 동시 사용 가능 → next는 표시하지 않음 + roomStatus.totalRemaining > 0 -> null // roomPaid==0 && roomFree>0 && global<=0 → 글로벌 next roomStatus.remainingPaid == 0 && roomStatus.remainingFree > 0 && globalStatus.totalRemaining <= 0 -> globalStatus.nextRechargeAtEpochMillis @@ -443,10 +445,7 @@ class ChatRoomService( } else if (globalNext == null) { roomNext } else { - maxOf( - roomNext, - globalNext - ) + maxOf(roomNext, globalNext) } } else { roomNext @@ -693,6 +692,8 @@ class ChatRoomService( val statusTotalRemaining = roomQuotaAfterConsume.totalRemaining val globalAfter = chatQuotaService.getStatus(member.id!!) val statusNextRechargeAt: Long? = when { + // 채팅 가능: totalRemaining>0 → next 표시하지 않음 + statusTotalRemaining > 0 -> null // totalRemaining==0이고 (global<=0) → max(roomNext, globalNext) statusTotalRemaining == 0 && globalAfter.totalRemaining <= 0 -> { val roomNext = roomQuotaAfterConsume.nextRechargeAtEpochMillis