Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSend
import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService
import org.springframework.http.ResponseEntity
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.security.access.AccessDeniedException
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
Expand All @@ -23,14 +25,16 @@ class ChatMessageController(
@GetMapping("/{roomId}/messages")
fun listMessages(
@PathVariable roomId: Long,
@AuthenticationPrincipal requesterId: Long?,
@RequestParam(required = false) after: Long?,
@RequestParam(defaultValue = "50") limit: Int,
): ResponseEntity<ApiResponse<List<ChatMessageResponse>>> {
val memberId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
val messages =
if (after == null) {
messageService.getlistbefore(roomId, limit)
messageService.getlistbefore(roomId, limit, memberId)
} else {
messageService.getlistafter(roomId, after)
messageService.getlistafter(roomId, after, memberId)
}
val responseMessages = messages.map(ChatMessageResponse::from)
return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = responseMessages))
Expand All @@ -39,9 +43,11 @@ class ChatMessageController(
@PostMapping("/{roomId}/messages")
fun sendMessage(
@PathVariable roomId: Long,
@AuthenticationPrincipal senderId: Long?,
@RequestBody req: ChatMessageSendRequest,
): ResponseEntity<ApiResponse<ChatMessageResponse>> {
val saved = messageService.send(roomId, req.senderId, req.content)
val memberId = senderId ?: throw AccessDeniedException("인증이 필요합니다.")
val saved = messageService.send(roomId, memberId, req.content)
val response = ChatMessageResponse.from(saved)
messagingTemplate.convertAndSend(
"/topic/userchat/$roomId",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import org.springframework.messaging.handler.annotation.DestinationVariable
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.Payload
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Controller
import java.security.Principal

@Controller
class ChatMessageSocketController(
Expand All @@ -18,9 +20,11 @@ class ChatMessageSocketController(
@MessageMapping("/userchat/{roomId}/messages")
fun handleMessage(
@DestinationVariable roomId: Long,
principal: Principal,
@Payload req: ChatMessageSendRequest,
) {
val saved = chatMessageService.send(roomId, req.senderId, req.content)
val senderId = principal.name.toLongOrNull() ?: throw AccessDeniedException("인증이 필요합니다.")
val saved = chatMessageService.send(roomId, senderId, req.content)
val response = ChatMessageResponse.from(saved)
messagingTemplate.convertAndSend(
"/topic/userchat/$roomId",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.back.koreaTravelGuide.domain.userChat.chatmessage.dto

data class ChatMessageSendRequest(
val senderId: Long,
val content: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package com.back.koreaTravelGuide.domain.userChat.chatmessage.service

import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.NoSuchElementException

@Service
class ChatMessageService(
Expand All @@ -15,24 +18,42 @@ class ChatMessageService(
fun getlistbefore(
roomId: Long,
limit: Int,
): List<ChatMessage> = messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()
requesterId: Long,
): List<ChatMessage> {
loadAuthorizedRoom(roomId, requesterId)
return messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()
}

@Transactional(readOnly = true)
fun getlistafter(
roomId: Long,
afterId: Long,
): List<ChatMessage> = messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId)
requesterId: Long,
): List<ChatMessage> {
loadAuthorizedRoom(roomId, requesterId)
return messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId)
}

@Transactional
fun send(
roomId: Long,
senderId: Long,
content: String,
): ChatMessage {
val room = loadAuthorizedRoom(roomId, senderId)
val saved = messageRepository.save(ChatMessage(roomId = roomId, senderId = senderId, content = content))
roomRepository.findById(roomId).ifPresent {
roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id))
}
roomRepository.save(room.copy(updatedAt = saved.createdAt, lastMessageId = saved.id))
return saved
}

private fun loadAuthorizedRoom(
roomId: Long,
memberId: Long,
): ChatRoom {
val room = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") }
if (room.guideId != memberId && room.userId != memberId) {
throw AccessDeniedException("user $memberId cannot access room $roomId")
}
return room
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.back.koreaTravelGuide.domain.userChat.chatroom.controller

import com.back.koreaTravelGuide.common.ApiResponse
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomDeleteRequest
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest
import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService
import org.springframework.http.ResponseEntity
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
Expand All @@ -22,26 +23,31 @@ class ChatRoomController(
// 같은 페어는 방 재사용
@PostMapping("/start")
fun startChat(
@AuthenticationPrincipal requesterId: Long?,
@RequestBody req: ChatRoomStartRequest,
): ResponseEntity<ApiResponse<ChatRoomResponse>> {
val room = roomService.exceptOneToOneRoom(req.guideId, req.userId)
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
val room = roomService.checkOneToOneRoom(req.guideId, req.userId, authenticatedId)
return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = ChatRoomResponse.from(room)))
}

@DeleteMapping("/{roomId}")
fun deleteRoom(
@PathVariable roomId: Long,
@RequestBody req: ChatRoomDeleteRequest,
@AuthenticationPrincipal requesterId: Long?,
): ResponseEntity<ApiResponse<Unit>> {
roomService.deleteByOwner(roomId, req.userId)
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
roomService.deleteByOwner(roomId, authenticatedId)
return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료"))
}

@GetMapping("/{roomId}")
fun get(
@PathVariable roomId: Long,
@AuthenticationPrincipal requesterId: Long?,
): ResponseEntity<ApiResponse<ChatRoomResponse>> {
val room = roomService.get(roomId)
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
val room = roomService.get(roomId, authenticatedId)
return ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = ChatRoomResponse.from(room)))
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.service
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
import org.springframework.security.access.AccessDeniedException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Instant
import java.util.NoSuchElementException

@Service
class ChatRoomService(
private val roomRepository: ChatRoomRepository,
private val messageRepository: ChatMessageRepository,
) {
@Transactional
fun exceptOneToOneRoom(
fun checkOneToOneRoom(
guideId: Long,
userId: Long,
requesterId: Long,
): ChatRoom {
checkParticipant(guideId, userId, requesterId)
// 1) 기존 방 재사용
roomRepository.findOneToOneRoom(guideId, userId)?.let { return it }

Expand All @@ -28,21 +32,46 @@ class ChatRoomService(
}

@Transactional(readOnly = true)
fun get(roomId: Long): ChatRoom =
roomRepository.findById(roomId)
.orElseThrow { NoSuchElementException("room not found: $roomId") }
fun get(
roomId: Long,
requesterId: Long,
): ChatRoom {
val room =
roomRepository.findById(roomId)
.orElseThrow { NoSuchElementException("room not found: $roomId") }
checkMember(room, requesterId)
return room
}

@Transactional
fun deleteByOwner(
roomId: Long,
requesterId: Long,
) {
val room = get(roomId)
val room = get(roomId, requesterId)
if (room.userId != requesterId) {
// 예외처리 임시
throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.")
throw AccessDeniedException("채팅방 생성자만 삭제할 수 있습니다.")
}
messageRepository.deleteByRoomId(roomId)
roomRepository.deleteById(roomId)
}

private fun checkParticipant(
guideId: Long,
userId: Long,
requesterId: Long,
) {
if (guideId != requesterId && userId != requesterId) {
throw AccessDeniedException("채팅방은 참여자만 생성할 수 있습니다.")
}
}

private fun checkMember(
room: ChatRoom,
requesterId: Long,
) {
if (room.guideId != requesterId && room.userId != requesterId) {
throw AccessDeniedException("채팅방에 접근할 수 없습니다.")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.back.koreaTravelGuide.domain.userChat.config

import com.back.koreaTravelGuide.common.security.JwtTokenProvider
import org.springframework.messaging.Message
import org.springframework.messaging.MessageChannel
import org.springframework.messaging.simp.stomp.StompCommand
import org.springframework.messaging.simp.stomp.StompHeaderAccessor
import org.springframework.messaging.support.ChannelInterceptor
import org.springframework.messaging.support.MessageHeaderAccessor
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException
import org.springframework.stereotype.Component

@Component
class UserChatStompAuthChannelInterceptor(
private val jwtTokenProvider: JwtTokenProvider,
) : ChannelInterceptor {
override fun preSend(
message: Message<*>,
channel: MessageChannel,
): Message<*>? {
val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java) ?: return message

if (accessor.command == StompCommand.CONNECT) {
val rawHeader =
accessor.getFirstNativeHeader("Authorization")
?: throw AuthenticationCredentialsNotFoundException("Authorization header is missing")
val token = rawHeader.removePrefix("Bearer ").trim()
if (!jwtTokenProvider.validateToken(token)) {
throw AuthenticationCredentialsNotFoundException("Invalid JWT token")
}
accessor.user = jwtTokenProvider.getAuthentication(token)
} else if (accessor.user == null) {
throw AuthenticationCredentialsNotFoundException("Unauthenticated STOMP request")
}

return message
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.back.koreaTravelGuide.domain.userChat.config

import org.springframework.context.annotation.Configuration
import org.springframework.messaging.simp.config.ChannelRegistration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
Expand All @@ -10,7 +11,9 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo

@Configuration
@EnableWebSocketMessageBroker
class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer {
class UserChatWebSocketConfig(
private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor,
) : WebSocketMessageBrokerConfigurer {
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry.addEndpoint("/ws/userchat")
.setAllowedOriginPatterns("*")
Expand All @@ -21,4 +24,8 @@ class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer {
registry.enableSimpleBroker("/topic")
registry.setApplicationDestinationPrefixes("/pub")
}

override fun configureClientInboundChannel(registration: ChannelRegistration) {
registration.interceptors(userChatStompAuthChannelInterceptor)
}
}