From f61c45e89a3446726c63edfb4681ddea62c655a0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 19 Aug 2025 18:47:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(character-comment):=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=8C=93=EA=B8=80/=EB=8B=B5=EA=B8=80=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=81=EC=84=B8=20=EC=9D=91?= =?UTF-8?q?=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(