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:
Klaus 2025-08-19 18:47:59 +09:00
parent 27ed9f61d0
commit f61c45e89a
7 changed files with 313 additions and 2 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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>
)

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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!!)
)
)
}

View File

@ -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(