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(