캐릭터 챗봇 #338
| @@ -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<CharacterComment> = 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 | ||||||
|  | } | ||||||
| @@ -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) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -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<CharacterReplyResponse> | ||||||
|  | ) | ||||||
| @@ -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<CharacterComment, Long> { | ||||||
|  |     fun findByChatCharacter_IdAndIsActiveTrueAndParentIsNullOrderByCreatedAtDesc( | ||||||
|  |         chatCharacterId: Long, | ||||||
|  |         pageable: Pageable | ||||||
|  |     ): List<CharacterComment> | ||||||
|  |  | ||||||
|  |     fun countByParent_IdAndIsActiveTrue(parentId: Long): Int | ||||||
|  |     fun findByParent_IdAndIsActiveTrueOrderByCreatedAtDesc(parentId: Long, pageable: Pageable): List<CharacterComment> | ||||||
|  |     fun findFirstByChatCharacter_IdAndIsActiveTrueOrderByCreatedAtDesc(chatCharacterId: Long): CharacterComment? | ||||||
|  |  | ||||||
|  |     // 전체(상위+답글) 활성 댓글 총 개수 | ||||||
|  |     fun countByChatCharacter_IdAndIsActiveTrue(chatCharacterId: Long): Int | ||||||
|  | } | ||||||
| @@ -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<CharacterCommentResponse> { | ||||||
|  |         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) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.controller | 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.Character | ||||||
| import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse | import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse | ||||||
| import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse | import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse | ||||||
| @@ -29,6 +30,7 @@ class ChatCharacterController( | |||||||
|     private val service: ChatCharacterService, |     private val service: ChatCharacterService, | ||||||
|     private val bannerService: ChatCharacterBannerService, |     private val bannerService: ChatCharacterBannerService, | ||||||
|     private val chatRoomService: ChatRoomService, |     private val chatRoomService: ChatRoomService, | ||||||
|  |     private val characterCommentService: CharacterCommentService, | ||||||
|  |  | ||||||
|     @Value("\${cloud.aws.cloud-front.host}") |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|     private val imageHost: String |     private val imageHost: String | ||||||
| @@ -148,6 +150,9 @@ class ChatCharacterController( | |||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |         // 최신 댓글 1개 조회 | ||||||
|  |         val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!) | ||||||
|  |  | ||||||
|         // 응답 생성 |         // 응답 생성 | ||||||
|         ApiResponse.ok( |         ApiResponse.ok( | ||||||
|             CharacterDetailResponse( |             CharacterDetailResponse( | ||||||
| @@ -162,7 +167,9 @@ class ChatCharacterController( | |||||||
|                 originalTitle = character.originalTitle, |                 originalTitle = character.originalTitle, | ||||||
|                 originalLink = character.originalLink, |                 originalLink = character.originalLink, | ||||||
|                 characterType = character.characterType, |                 characterType = character.characterType, | ||||||
|                 others = others |                 others = others, | ||||||
|  |                 latestComment = latestComment, | ||||||
|  |                 totalComments = characterCommentService.getTotalCommentCount(character.id!!) | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.dto | package kr.co.vividnext.sodalive.chat.character.dto | ||||||
|  |  | ||||||
| import kr.co.vividnext.sodalive.chat.character.CharacterType | import kr.co.vividnext.sodalive.chat.character.CharacterType | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse | ||||||
|  |  | ||||||
| data class CharacterDetailResponse( | data class CharacterDetailResponse( | ||||||
|     val characterId: Long, |     val characterId: Long, | ||||||
| @@ -14,7 +15,9 @@ data class CharacterDetailResponse( | |||||||
|     val originalTitle: String?, |     val originalTitle: String?, | ||||||
|     val originalLink: String?, |     val originalLink: String?, | ||||||
|     val characterType: CharacterType, |     val characterType: CharacterType, | ||||||
|     val others: List<OtherCharacter> |     val others: List<OtherCharacter>, | ||||||
|  |     val latestComment: CharacterCommentResponse?, | ||||||
|  |     val totalComments: Int | ||||||
| ) | ) | ||||||
|  |  | ||||||
| data class OtherCharacter( | data class OtherCharacter( | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user