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:
parent
27ed9f61d0
commit
f61c45e89a
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue