feat(character-comment): 캐릭터 댓글/답글 API 추가 및 상세 응답 확장
- 캐릭터 댓글 엔티티/레포지토리/서비스/컨트롤러 추가
  - 댓글 작성 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 호출 최소화
			
			
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
| 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!!) | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -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<OtherCharacter> | ||||
|     val others: List<OtherCharacter>, | ||||
|     val latestComment: CharacterCommentResponse?, | ||||
|     val totalComments: Int | ||||
| ) | ||||
|  | ||||
| data class OtherCharacter( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user