diff --git a/build.gradle b/build.gradle index 6abc0eb..316fac3 100644 --- a/build.gradle +++ b/build.gradle @@ -63,7 +63,20 @@ dependencies { implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // web socket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + //mongo + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + // 직렬화 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + + } tasks.named('test') { diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt new file mode 100644 index 0000000..78033e7 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt @@ -0,0 +1,39 @@ +package busanVibe.busan.domain.chat.controller + +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageResponseDTO +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageSendDTO +import busanVibe.busan.domain.chat.service.ChatMongoService +import busanVibe.busan.global.apiPayload.exception.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.web.bind.annotation.GetMapping +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.RestController + +@Tag(name = "채팅 관련 API") +@RestController +@RequestMapping("/chat") +class ChatRestController( + private val chatMongoService: ChatMongoService, +) { + + val log: Logger = LoggerFactory.getLogger(ChatRestController::class.java) + + @PostMapping("/send") + fun sendMessage(@RequestBody chatMessage: ChatMessageSendDTO) { + log.info("POST /chat/send - 메시지 수신: $chatMessage") + chatMongoService.saveAndPublish(chatMessage) + } + + @GetMapping("/history") + @Operation(summary = "채팅 조회 API", description = "채팅 목록을 조회합니다.") + fun getHistory(): ApiResponse { + val chatList = chatMongoService.getChatHistory() + return ApiResponse.onSuccess(chatList) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatWebSocketController.kt b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatWebSocketController.kt new file mode 100644 index 0000000..482bbb3 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatWebSocketController.kt @@ -0,0 +1,28 @@ +package busanVibe.busan.domain.chat.controller + +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageSendDTO +import busanVibe.busan.domain.chat.service.ChatMongoService +import busanVibe.busan.domain.user.data.User +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.MessageMapping +import org.springframework.messaging.handler.annotation.Payload +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.stereotype.Controller +import java.security.Principal + +@Controller +class ChatWebSocketController( + private val chatMongoService: ChatMongoService, +) { + + /** + * websocket '/pub/chat/message' 로 들어오는 메시징 처리 + */ +// @MessageMapping("/chat/message") +// fun handleMessage(@Payload chatMessage: ChatMessageSendDTO, authentication: Authentication) { +// val user = authentication.principal as User +// chatMongoService.saveAndPublish(chatMessage, user) +// } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/domain/ChatMessage.kt b/src/main/kotlin/busanVibe/busan/domain/chat/domain/ChatMessage.kt new file mode 100644 index 0000000..8c66b38 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/domain/ChatMessage.kt @@ -0,0 +1,21 @@ +package busanVibe.busan.domain.chat.domain + +import busanVibe.busan.domain.chat.enums.MessageType +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.LocalDateTime + +@Document(collection = "chat_messages") +data class ChatMessage( + @Id + val id: String? = null, + + val type: MessageType, + + val userId: Long?, + + val message: String, + + val time: LocalDateTime, + +) \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt new file mode 100644 index 0000000..a086ea0 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/ChatRequest.kt @@ -0,0 +1,15 @@ +package busanVibe.busan.domain.chat.dto.openai + +class ChatRequest( + val model: String, + val messages: List, + val n: Int, + val max_tokens: Int +) { + constructor(model: String, prompt: String) : this( + model = model, + messages = listOf(ChatMessageDTO("user", prompt)), + n = 1, + max_tokens = 100 + ) +} diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/MessageDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/MessageDTO.kt new file mode 100644 index 0000000..a321dab --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/openai/MessageDTO.kt @@ -0,0 +1,27 @@ +package busanVibe.busan.domain.chat.dto.openai + +data class ChatMessageDTO( + val role: String, + val content: String +) + +data class ChatRequestDto( + val model: String, + val messages: List, + val n: Int = 1 +) { + constructor(model: String, prompt: String): this( + model = model, + messages = listOf(ChatMessageDTO("user", prompt)), + n = 1 + ) +} + +data class ChatResponse( + val choices: List +) { + data class Choice( + val index: Int, + val message: ChatMessageDTO + ) +} diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageReceiveDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageReceiveDTO.kt new file mode 100644 index 0000000..4c82d6e --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageReceiveDTO.kt @@ -0,0 +1,17 @@ +package busanVibe.busan.domain.chat.dto.websocket + +import busanVibe.busan.domain.chat.enums.MessageType +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.LocalDateTime + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class ChatMessageReceiveDTO( + var userId: Long? = null, + var name: String, + var imageUrl: String? = null, + var message: String, + var time: LocalDateTime, + var type: MessageType? = null, +) { +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageResponseDTO.kt new file mode 100644 index 0000000..3551768 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageResponseDTO.kt @@ -0,0 +1,25 @@ +package busanVibe.busan.domain.chat.dto.websocket + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.LocalDateTime + +class ChatMessageResponseDTO { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class ListDto( + val chatList: List + ) + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class ChatInfoDto( + val userName: String, + val userImage: String?, + val content: String, + val dateTime: LocalDateTime?, + @get:JsonProperty("is_my") + val isMy: Boolean + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt new file mode 100644 index 0000000..b1a74b6 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt @@ -0,0 +1,16 @@ +package busanVibe.busan.domain.chat.dto.websocket + +import busanVibe.busan.domain.chat.enums.MessageType +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming +import java.time.LocalDateTime + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class ChatMessageSendDTO( +// var userId: Long? = null, + var type: MessageType? = null, // 메시지 타입 + var message: String? = null, // 메시지 + var time: LocalDateTime? = null, // 전송 시간 +){ + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/UserInfoResponse.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/UserInfoResponse.kt new file mode 100644 index 0000000..0582109 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/UserInfoResponse.kt @@ -0,0 +1,10 @@ +package busanVibe.busan.domain.chat.dto.websocket + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +class UserInfoResponse( + val userId: Long +) { +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/WebSocketErrorDTO.kt b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/WebSocketErrorDTO.kt new file mode 100644 index 0000000..958acaa --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/WebSocketErrorDTO.kt @@ -0,0 +1,10 @@ +package busanVibe.busan.domain.chat.dto.websocket + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class WebSocketErrorDTO( + val errorCode: String, + val errorMessage: String +) \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/enums/MessageType.kt b/src/main/kotlin/busanVibe/busan/domain/chat/enums/MessageType.kt new file mode 100644 index 0000000..32d9b72 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/enums/MessageType.kt @@ -0,0 +1,5 @@ +package busanVibe.busan.domain.chat.enums + +enum class MessageType { + CHAT, BOT +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt b/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt new file mode 100644 index 0000000..15d22e1 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/repository/ChatMongoRepository.kt @@ -0,0 +1,8 @@ +package busanVibe.busan.domain.chat.repository + +import busanVibe.busan.domain.chat.domain.ChatMessage +import org.springframework.data.mongodb.repository.MongoRepository + +interface ChatMongoRepository: MongoRepository { + fun findAllByOrderByTime(): List +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt new file mode 100644 index 0000000..b34e014 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatGPTService.kt @@ -0,0 +1,43 @@ +package busanVibe.busan.domain.chat.service + +import busanVibe.busan.domain.chat.dto.openai.ChatRequest +import busanVibe.busan.domain.chat.dto.openai.ChatResponse +import busanVibe.busan.global.config.openai.ChatGPTConfig +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpEntity +import org.springframework.stereotype.Service +import org.springframework.web.client.RestTemplate + +@Service +class ChatGPTService( + private val chatGPTConfig: ChatGPTConfig, + + @Value("\${openai.model}") + private val model: String, + + @Value("\${openai.secret-key}") + private val url: String + +) { + + fun prompt(prompt: String): String { + + // 토큰 정보가 포함된 header 가져오기 + val headers = chatGPTConfig.httpHeaders() + + // create request + val chatRequest = ChatRequest(model, prompt) + + // 통신을 위한 RestTemplate 구성 + val requestEntity = HttpEntity(chatRequest, headers) + + val restTemplate = RestTemplate() + val response: ChatResponse? = restTemplate.postForObject(url, requestEntity, ChatResponse::class.java) + + if (response?.choices.isNullOrEmpty()) { + throw RuntimeException("No choices returned from ChatGPT.") + } + + return response!!.choices[0].message.content + } +} diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt new file mode 100644 index 0000000..bf8e20d --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt @@ -0,0 +1,96 @@ +package busanVibe.busan.domain.chat.service + +import busanVibe.busan.domain.chat.domain.ChatMessage +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageReceiveDTO +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageResponseDTO +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageSendDTO +import busanVibe.busan.domain.chat.enums.MessageType +import busanVibe.busan.domain.chat.repository.ChatMongoRepository +import busanVibe.busan.domain.user.repository.UserRepository +import busanVibe.busan.domain.user.service.login.AuthService +import busanVibe.busan.global.apiPayload.code.ErrorReasonDTO +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.http.HttpStatus +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class ChatMongoService( + private val chatMongoRepository: ChatMongoRepository, + private val redisPublisher: RedisPublisher, + private val topic: ChannelTopic, + private val userRepository: UserRepository, + private val messagingTemplate: SimpMessagingTemplate +) { + + fun saveAndPublish(chatMessage: ChatMessageSendDTO) { + + // 현재 유저 조회 + val currentUser = AuthService().getCurrentUser() + + // 값 없으면 "" 저장 + val message = chatMessage.message ?: "" + + // 200자 넘어갈 시 예외처리 + if (message.length > 200) { + val errorDTO = ErrorReasonDTO( + httpStatus = HttpStatus.BAD_REQUEST, + code = ErrorStatus.CHAT_INVALID_LENGTH.code, + message = ErrorStatus.CHAT_INVALID_LENGTH.message, + isSuccess = false + ) + messagingTemplate.convertAndSend("/sub/chat/error", errorDTO) + return // 또는 throw 후 처리 + } + + // 채팅 객체 생성 + val document = ChatMessage( + type = chatMessage.type?: MessageType.CHAT, + userId = currentUser.id, + message = chatMessage.message?:"", + time = chatMessage.time?: LocalDateTime.now(), + ) + + // 채팅 저장 + chatMongoRepository.save(document) + + val receiveDto = ChatMessageReceiveDTO( + name = currentUser.nickname, + imageUrl = currentUser.profileImageUrl, + message = document.message, + time = document.time, + type = document.type, + userId = currentUser.id + ) + + redisPublisher.publish(topic, receiveDto) + } + + fun getChatHistory(): ChatMessageResponseDTO.ListDto { + val currentUser = AuthService().getCurrentUser() + + val chatHistory = chatMongoRepository.findAllByOrderByTime() + + val dtoList = chatHistory.mapNotNull { chat -> + val user = chat.userId?.let { userRepository.findById(it).orElse(null) } + + if (user == null) return@mapNotNull null + + ChatMessageResponseDTO.ChatInfoDto( + userName = user.nickname, + userImage = user.profileImageUrl, + content = chat.message, + dateTime = chat.time, + isMy = user.id == currentUser.id + ) + } + + return ChatMessageResponseDTO.ListDto(dtoList) + } + + + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisPublisher.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisPublisher.kt new file mode 100644 index 0000000..57d18d8 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisPublisher.kt @@ -0,0 +1,17 @@ +package busanVibe.busan.domain.chat.service + +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageReceiveDTO +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.stereotype.Component + +@Component +class RedisPublisher( + private val redisTemplate: RedisTemplate, +) { + + fun publish(topic: ChannelTopic, message: ChatMessageReceiveDTO) { + // 문자열이 아니라 객체를 그대로 넘긴다 + redisTemplate.convertAndSend(topic.topic, message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt new file mode 100644 index 0000000..4472de4 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/RedisSubscriber.kt @@ -0,0 +1,30 @@ +package busanVibe.busan.domain.chat.service + +import busanVibe.busan.domain.chat.dto.websocket.ChatMessageReceiveDTO +import org.slf4j.LoggerFactory +import org.springframework.data.redis.connection.Message +import org.springframework.data.redis.connection.MessageListener +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Component + +@Component +class RedisSubscriber( + private val messagingTemplate: SimpMessagingTemplate, + private val serializer: GenericJackson2JsonRedisSerializer, +): MessageListener { + + private val log = LoggerFactory.getLogger(RedisSubscriber::class.java) + + override fun onMessage(message: Message, pattern: ByteArray?) { + val chatMessage = serializer.deserialize(message.body, ChatMessageReceiveDTO::class.java) as? ChatMessageReceiveDTO + if (chatMessage != null) { + log.info("🔔 수신된 메시지: {}", chatMessage) + messagingTemplate.convertAndSend("/sub/chatroom", chatMessage) + } else { + log.warn("❌ 수신된 메시지를 ChatMessageDTO로 변환할 수 없습니다.") + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/converter/FestivalConverter.kt b/src/main/kotlin/busanVibe/busan/domain/festival/converter/FestivalConverter.kt index 709f74e..8dd87a0 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/converter/FestivalConverter.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/converter/FestivalConverter.kt @@ -15,7 +15,7 @@ class FestivalConverter { img = festivalImageMap[festivalId], startDate = convertFestivalDate(festival.startDate), endDate = convertFestivalDate(festival.endDate), - region = festival.place, + address = festival.place, isLike = userLikedFestivalIdList.contains(festivalId), likeCount = likeCountMap[festivalId] ?: 0, ) diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt index f394b67..5f3e191 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalDetailsDTO.kt @@ -16,7 +16,7 @@ class FestivalDetailsDTO { val isLike: Boolean, val startDate: String, val endDate: String, - val region: String, + val address: String, val phone: String, val fee: Int, val siteUrl: String, diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt index bfc5641..9f3320a 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt @@ -18,7 +18,7 @@ class FestivalListResponseDTO { val img: String?, val startDate: String, val endDate: String, - val region: String, + val address: String, @get:JsonProperty("is_like") val isLike: Boolean, val likeCount: Int, diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalQueryService.kt index 8a294c7..d8f1d31 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalQueryService.kt @@ -116,7 +116,7 @@ class FestivalQueryService( isLike = likeList.any { it.user.id == currentUser.id }, startDate = FestivalConverter().convertFestivalDate(festival.startDate), endDate = FestivalConverter().convertFestivalDate(festival.endDate), - region = festival.place, + address = festival.place, fee = festival.fee, siteUrl = festival.siteUrl, introduce = festival.introduction, diff --git a/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt index 5934f74..759f2a9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/home/dto/HomeResponseDTO.kt @@ -21,7 +21,7 @@ class HomeResponseDTO { val type: String, val image: String?, val congestionLevel: Int, - val region: String + val address: String ) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) @@ -33,7 +33,7 @@ class HomeResponseDTO { val image: String?, val latitude: Double, val longitude: Double, - val region: String, + val address: String, @get:JsonProperty("is_liked") val isLiked: Boolean ) diff --git a/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt index 65ab9fe..2a12a6e 100644 --- a/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/home/service/HomeQueryService.kt @@ -50,7 +50,7 @@ class HomeQueryService( type = place.type.korean, image = place.placeImages.firstOrNull()?.imgUrl, congestionLevel = congestion, - region = place.address + address = place.address ) } @@ -73,7 +73,7 @@ class HomeQueryService( image = place.placeImages.firstOrNull()?.imgUrl, latitude = place.latitude.toDouble(), longitude = place.longitude.toDouble(), - region = place.address, + address = place.address, isLiked = place.placeLikes.any { it.user == currentUser } ) } diff --git a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceQueryResult.kt b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceQueryResult.kt deleted file mode 100644 index ef5ea24..0000000 --- a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceQueryResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package busanVibe.busan.domain.place.dto - -import busanVibe.busan.domain.place.domain.Place -import busanVibe.busan.domain.place.domain.PlaceLike -import busanVibe.busan.domain.review.domain.Review - -class PlaceQueryResult ( - val place: Place, - val placeLikes: List, - val reviews: List -){ - - - -} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt b/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt index 9fb425a..7f9b03d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/search/dto/SearchResultDTO.kt @@ -19,7 +19,7 @@ class SearchResultDTO { val name: String, val latitude: Double? = null, val longitude: Double? = null, - val region: String, + val address: String, val congestionLevel: Int? = null, @get:JsonProperty("is_liked") val isLiked: Boolean, diff --git a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt index 6f506b4..2cb6ffb 100644 --- a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt @@ -51,7 +51,7 @@ class SearchQueryService( typeEn = InfoType.FESTIVAL.en, id = festival.id, name = festival.name, - region = festival.place, + address = festival.place, isLiked = festival.festivalLikes.any { it.user == currentUser }, startDate = festival.startDate.toString(), endDate = festival.endDate.toString(), @@ -68,7 +68,7 @@ class SearchQueryService( name = place.name, latitude = place.latitude.toDouble(), longitude = place.longitude.toDouble(), - region = place.address, + address = place.address, congestionLevel = placeRedisUtil.getRedisCongestion(place.id), isLiked = place.placeLikes.any { it.user == currentUser }, startDate = null, diff --git a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt index 9748412..982a157 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserController.kt @@ -2,37 +2,36 @@ package busanVibe.busan.domain.user.controller import busanVibe.busan.domain.user.data.dto.UserResponseDTO import busanVibe.busan.domain.user.service.UserCommandService +import busanVibe.busan.domain.user.util.LoginRedirectUtil import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletResponse import org.slf4j.LoggerFactory import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.net.URI +import java.net.URLEncoder @Tag(name = "유저 관련 API", description = "로그인과 분리할수도 있습니다") @RestController @RequestMapping("/users") class UserController ( - private val userCommandService: UserCommandService + private val userCommandService: UserCommandService, + private val loginRedirectUtil: LoginRedirectUtil ){ private val log = LoggerFactory.getLogger(UserController::class.java) @GetMapping("/oauth/kakao") - fun callBack(@RequestParam("code") code: String): ResponseEntity> { - log.info("login...") - log.info("code: $code") - + fun callBack(@RequestParam("code") code: String): ResponseEntity { val userResponse: UserResponseDTO.LoginDto = userCommandService.loginOrRegisterByKakao(code) - return ResponseEntity.ok() - .header( - HttpHeaders.AUTHORIZATION, - "Bearer " + userResponse.tokenResponseDTO.accessToken - ) - .body(ApiResponse.onSuccess(userResponse)) + val redirectHeader = loginRedirectUtil.getRedirectHeader(userResponse) + return ResponseEntity.status(HttpStatus.FOUND).headers(redirectHeader).build() } diff --git a/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt b/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt index 4f89107..819e0cc 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt @@ -47,7 +47,7 @@ class User( } override fun getUsername(): String? { - return email + return id.toString() } } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/service/login/AuthService.kt b/src/main/kotlin/busanVibe/busan/domain/user/service/login/AuthService.kt index e7fe247..40fa81d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/service/login/AuthService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/service/login/AuthService.kt @@ -1,7 +1,6 @@ package busanVibe.busan.domain.user.service.login import busanVibe.busan.domain.user.data.User -import busanVibe.busan.domain.user.repository.UserRepository import busanVibe.busan.global.apiPayload.code.status.ErrorStatus import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler import org.springframework.security.core.context.SecurityContextHolder @@ -9,7 +8,6 @@ import org.springframework.security.core.context.SecurityContextHolder class AuthService( ) { - fun getCurrentUser(): User { val authentication = SecurityContextHolder.getContext().authentication diff --git a/src/main/kotlin/busanVibe/busan/domain/user/util/LoginRedirectUtil.kt b/src/main/kotlin/busanVibe/busan/domain/user/util/LoginRedirectUtil.kt new file mode 100644 index 0000000..5a4f061 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/user/util/LoginRedirectUtil.kt @@ -0,0 +1,28 @@ +package busanVibe.busan.domain.user.util + +import busanVibe.busan.domain.user.data.dto.UserResponseDTO +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.stereotype.Component +import java.net.URI +import java.net.URLEncoder + +@Component +class LoginRedirectUtil( + @Value("\${spring.kakao.deep-link}") + private val deepLink: String +){ + + fun getRedirectHeader(userResponse: UserResponseDTO.LoginDto): HttpHeaders{ + val accessTokenEncoded = URLEncoder.encode(userResponse.tokenResponseDTO.accessToken, "UTF-8") + val refreshTokenEncoded = URLEncoder.encode(userResponse.tokenResponseDTO.refreshToken, "UTF-8") + + val redirectUrl = "$deepLink?accessToken=$accessTokenEncoded&refreshToken=$refreshTokenEncoded" + + val headers = HttpHeaders() + headers.location = URI.create(redirectUrl) + return headers + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt index c32042b..22c3500 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt @@ -31,7 +31,10 @@ enum class ErrorStatus( SEARCH_INVALID_CONDITION(HttpStatus.BAD_REQUEST, "SEARCH4002", "잘못된 검색 조건입니다."), // 인증 관련 에러 - AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH4010", "인증에 실패했습니다."); + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH4010", "인증에 실패했습니다."), + + // 채팅 관련 에러 + CHAT_INVALID_LENGTH(HttpStatus.BAD_REQUEST, "CHAT4001", "글자 수는 200자로 제한됩니다."), ; diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/exception/ExceptionAdvice.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/exception/ExceptionAdvice.kt index 5ba7e9d..23c17b6 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/exception/ExceptionAdvice.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/exception/ExceptionAdvice.kt @@ -9,6 +9,7 @@ import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestController @@ -18,7 +19,7 @@ import org.springframework.web.context.request.WebRequest import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler -@RestControllerAdvice(annotations = [RestController::class]) +@RestControllerAdvice(annotations = [RestController::class, Controller::class]) class ExceptionAdvice : ResponseEntityExceptionHandler() { private val log = LoggerFactory.getLogger(this::class.java) diff --git a/src/main/kotlin/busanVibe/busan/global/config/CorsConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/CorsConfig.kt new file mode 100644 index 0000000..edec989 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/CorsConfig.kt @@ -0,0 +1,18 @@ +package busanVibe.busan.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.CorsRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class CorsConfig: WebMvcConfigurer { + + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping("/**") + .allowedOrigins("https://www.yourdomain.com", "http://127.0.0.1:5500") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowCredentials(true) + .maxAge(3600) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/JacksonConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/JacksonConfig.kt new file mode 100644 index 0000000..c371c60 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/JacksonConfig.kt @@ -0,0 +1,21 @@ +package busanVibe.busan.global.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class JacksonConfig { + + @Bean + fun objectMapper(): ObjectMapper { + return ObjectMapper() + .registerModule(JavaTimeModule()) + .registerModule(KotlinModule.Builder().build()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/mongo/MongoConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/mongo/MongoConfig.kt new file mode 100644 index 0000000..cb4de4e --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/mongo/MongoConfig.kt @@ -0,0 +1,27 @@ +package busanVibe.busan.global.config.mongo + +import com.mongodb.client.MongoClient +import com.mongodb.client.MongoClients +import org.springframework.boot.autoconfigure.mongo.MongoProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories + +@Configuration +@EnableMongoRepositories(basePackages = ["busanVibe.busan.domain.chat.repository"]) +class MongoConfig( + private val mongoProperties: MongoProperties +) { + + @Bean + fun mongoClient(): MongoClient { + return MongoClients.create(mongoProperties.uri) + } + + @Bean + fun mongoTemplate(): MongoTemplate { + return MongoTemplate(mongoClient(), mongoProperties.database) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/openai/ChatGPTConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/openai/ChatGPTConfig.kt new file mode 100644 index 0000000..c85bcf9 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/openai/ChatGPTConfig.kt @@ -0,0 +1,45 @@ +package busanVibe.busan.global.config.openai + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.web.client.RestTemplate + +@Configuration +class ChatGPTConfig( + + @Value("\${openai.secret-key}") + private val secretKey: String, + + @Value("\${openai.model}") + private val model: String + +) { + + @Bean + fun resTemplates(): RestTemplate { + return RestTemplate() + } + + @Bean + fun httpHeaders(): HttpHeaders { + val headers = HttpHeaders() + headers.set("Authorization", "Bearer $secretKey") + headers.setContentType(MediaType.APPLICATION_JSON) + + return headers + } + + fun getSecretKey(): String { + return secretKey + } + + fun getModel():String { + return model + } + + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/redis/RedisConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/redis/RedisConfig.kt new file mode 100644 index 0000000..18c7ca6 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/redis/RedisConfig.kt @@ -0,0 +1,71 @@ +package busanVibe.busan.global.config.redis + +import busanVibe.busan.domain.chat.service.RedisSubscriber +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +class RedisConfig( + private val objectMapper: ObjectMapper +) { + + @Bean + fun redisTemplate(connectionFactory: RedisConnectionFactory, genericJackson2JsonRedisSerializer: GenericJackson2JsonRedisSerializer): RedisTemplate{ + + val redisTemplate = RedisTemplate() + + redisTemplate.connectionFactory = connectionFactory + + redisTemplate.keySerializer = StringRedisSerializer() + redisTemplate.valueSerializer = genericJackson2JsonRedisSerializer + redisTemplate.hashKeySerializer = StringRedisSerializer() + redisTemplate.hashValueSerializer = genericJackson2JsonRedisSerializer + + return redisTemplate + } + + @Bean + fun redisMessageListenerContainer( + connectionFactory: RedisConnectionFactory, + listenerAdapter: MessageListenerAdapter + ): RedisMessageListenerContainer { + val container = RedisMessageListenerContainer() + container.setConnectionFactory(connectionFactory) + container.addMessageListener(listenerAdapter, ChannelTopic("chatroom")) + return container + } + + @Bean + fun listenerAdapter(subscriber: RedisSubscriber, genericJackson2JsonRedisSerializer: GenericJackson2JsonRedisSerializer): MessageListenerAdapter { + val adapter = MessageListenerAdapter(subscriber, "onMessage") + adapter.setSerializer(genericJackson2JsonRedisSerializer) + return adapter + } + + @Bean + fun topic(): ChannelTopic = ChannelTopic("chatroom") + + @Bean + fun genericJackson2JsonRedisSerializer(): GenericJackson2JsonRedisSerializer { + val mapper = objectMapper + .registerModule(JavaTimeModule()) + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ) + return GenericJackson2JsonRedisSerializer(mapper) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/security/JwtTokenProvider.kt b/src/main/kotlin/busanVibe/busan/global/config/security/JwtTokenProvider.kt index 54e607e..ce735db 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/security/JwtTokenProvider.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/security/JwtTokenProvider.kt @@ -18,7 +18,6 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication -import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.stereotype.Component import java.nio.charset.StandardCharsets @@ -49,7 +48,7 @@ class JwtTokenProvider( } fun createToken(user: User) : TokenResponseDto { - val claims: Claims = Jwts.claims().setSubject(user.email) + val claims: Claims = Jwts.claims().setSubject(user.id.toString()) val now: Date = Date() val accessToken: String = Jwts.builder() @@ -101,8 +100,8 @@ class JwtTokenProvider( } fun getAuthentication(token: String): Authentication { - val email: String = getEmailFromToken(token) - val user:User = userRepository.findByEmail(email) + val id: Long = getIdFromToken(token).toLong() + val user:User = userRepository.findById(id) .orElseThrow { ExceptionHandler(ErrorStatus.USER_NOT_FOUND) } return UsernamePasswordAuthenticationToken( @@ -111,7 +110,7 @@ class JwtTokenProvider( } - fun getEmailFromToken(token:String):String{ + fun getIdFromToken(token:String):String{ return Jwts.parserBuilder() .setSigningKey(key) .build() diff --git a/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt index 4af769d..73cef23 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt @@ -3,7 +3,6 @@ package busanVibe.busan.global.config.security import lombok.RequiredArgsConstructor import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer @@ -27,27 +26,28 @@ class SecurityConfig { } @Bean - fun securityFilterChain(http: HttpSecurity, jwtTokenProvider: JwtTokenProvider): SecurityFilterChain{ - - http.formLogin { it.disable() } - .httpBasic { it.disable() } - .csrf { it.disable() } - .cors { it.configurationSource(corsConfigurationSource()) } + fun securityFilterChain(http: HttpSecurity, jwtTokenProvider: JwtTokenProvider): SecurityFilterChain { + http .headers { it.frameOptions { it.disable() } } - .sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .csrf { csrf -> csrf.disable() } + .cors { it.configurationSource(corsConfigurationSource()) } .authorizeHttpRequests { - it.requestMatchers( + it + .requestMatchers( "/", "/home", "/swagger-ui/**", "/v3/api-docs/**", - "/users/oauth/kakao" - ).permitAll() - .anyRequest() - .authenticated() } - .addFilterBefore( - JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java - ) + "/users/oauth/kakao", + "/ws-chat/**", + ).permitAll() // ws-chat 경로 완전 허용 + .anyRequest().authenticated() + } + // JWT 필터 등은 유지 + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .addFilterBefore(JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java) return http.build() } @@ -56,24 +56,19 @@ class SecurityConfig { fun corsConfigurationSource(): CorsConfigurationSource { - val configuration: CorsConfiguration = CorsConfiguration() - - configuration.setAllowedOriginPatterns( - listOf( - "http://localhost:5173", - "http://localhost:8080", - "https://busanvibe.site", - "https://*.busanvibe.site", - - ) + val configuration = CorsConfiguration() + configuration.allowedOriginPatterns = listOf( + "http://localhost:*", + "http://127.0.0.1:*", + "https://busanvibe.site", + "https://*.busanvibe.site" ) - - configuration.allowedHeaders = listOf("*") configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") + configuration.allowedHeaders = listOf("*") configuration.allowCredentials = true configuration.maxAge = 3600L - val source: UrlBasedCorsConfigurationSource = UrlBasedCorsConfigurationSource() + val source = UrlBasedCorsConfigurationSource() source.registerCorsConfiguration("/**", configuration) return source } diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt new file mode 100644 index 0000000..f85ae41 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/AuthChannelInterceptorAdapter.kt @@ -0,0 +1,50 @@ +package busanVibe.busan.global.config.webSocket + +import busanVibe.busan.domain.user.repository.UserRepository +import busanVibe.busan.global.config.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.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component + +@Component +class AuthChannelInterceptorAdapter( + private val jwtTokenProvider: JwtTokenProvider, + private val userRepository: UserRepository +) : ChannelInterceptor { + + override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? { + val accessor = StompHeaderAccessor.wrap(message) + + if (StompCommand.CONNECT == accessor.command) { + val authHeader = accessor.getFirstNativeHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw IllegalArgumentException("No Authorization header") + } + + val token = authHeader.substring(7) + if (!jwtTokenProvider.validateToken(token)) { + throw IllegalArgumentException("Invalid token") + } + + val userId = jwtTokenProvider.getIdFromToken(token) + val user = userRepository.findById(userId.toLong()).orElseThrow { + IllegalArgumentException("User not found") + } + + println("웹소켓 연결 유저: id=${user.id}, email=${user.email}") + + val auth = jwtTokenProvider.getAuthentication(token) + accessor.user = auth + SecurityContextHolder.getContext().authentication = auth + } + + return message + } + + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt new file mode 100644 index 0000000..cb98980 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/JwtHandshakeInterceptor.kt @@ -0,0 +1,51 @@ +package busanVibe.busan.global.config.webSocket + +import busanVibe.busan.domain.user.repository.UserRepository +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler +import busanVibe.busan.global.config.security.JwtTokenProvider +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.http.server.ServletServerHttpRequest +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.HandshakeInterceptor + +class JwtHandshakeInterceptor( + private val jwtTokenProvider: JwtTokenProvider, + private val userRepository: UserRepository +) : HandshakeInterceptor { + + override fun beforeHandshake( + request: ServerHttpRequest, response: ServerHttpResponse, + wsHandler: WebSocketHandler, attributes: MutableMap + ): Boolean { + val servletRequest = (request as? ServletServerHttpRequest)?.servletRequest + val authHeader = servletRequest?.getHeader("Authorization") ?: return false + if (!authHeader.startsWith("Bearer ")) return false + val token = authHeader.substring(7) + + if (!jwtTokenProvider.validateToken(token)) { + return false + } + + val userEmail = jwtTokenProvider.getIdFromToken(token) + val user = userRepository.findByEmail(userEmail) + .orElseThrow { ExceptionHandler(ErrorStatus.USER_NOT_FOUND) } + + // Spring Security 인증 객체 생성 + val auth = UsernamePasswordAuthenticationToken(user, null, user.authorities) + + // WebSocket session attributes에 Principal로 넣어줌 + attributes["principal"] = auth + + return true + } + + override fun afterHandshake( + request: ServerHttpRequest, response: ServerHttpResponse, + wsHandler: WebSocketHandler, exception: Exception? + ) { + // ... + } +} diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/StompEventListener.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/StompEventListener.kt new file mode 100644 index 0000000..49de5bf --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/StompEventListener.kt @@ -0,0 +1,27 @@ +package busanVibe.busan.global.config.webSocket + +import org.slf4j.LoggerFactory +import org.springframework.context.event.EventListener +import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.stereotype.Component +import org.springframework.web.socket.messaging.SessionConnectedEvent + +@Component +class StompEventListener( + private val simpleMessageTemplate: SimpMessagingTemplate +) { + + private val log = LoggerFactory.getLogger(StompEventListener::class.java) + + @EventListener + fun handleConnectEvent(event: SessionConnectedEvent){ + log.info("ebSocket connected: {}", event.message.headers) + } + + @EventListener + fun handleDisconnectEvent(event: SessionConnectedEvent){ + log.info("ebSocket disconnected: {}", event.message.headers.id) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt new file mode 100644 index 0000000..05c48b0 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/global/config/webSocket/WebSocketConfig.kt @@ -0,0 +1,38 @@ +package busanVibe.busan.global.config.webSocket + +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 +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer + +@Configuration +@EnableWebSocketMessageBroker +class WebSocketConfig( + private val authChannelInterceptorAdapter: AuthChannelInterceptorAdapter, +) : WebSocketMessageBrokerConfigurer { + + override fun configureMessageBroker(config: MessageBrokerRegistry) { + config.enableSimpleBroker("/sub") + config.setApplicationDestinationPrefixes("/pub") + } + + override fun registerStompEndpoints(registry: StompEndpointRegistry) { + registry.addEndpoint("/ws-chat") + .setAllowedOriginPatterns( + "http://localhost:*", + "http://127.0.0.1:*", + "https://busanvibe.site", + "https://*.busanvibe.site" + ) +// .addInterceptors(JwtHandshakeInterceptor(jwtTokenProvider, userRepository)) + .withSockJS() + .setHeartbeatTime(10000) + } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(authChannelInterceptorAdapter) + } + +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5cafeb0..b5ce237 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,6 +4,10 @@ spring: logging: level: root: info + org.springframework.web.socket: DEBUG + org.springframework.messaging: DEBUG + org.springframework.messaging.converter: DEBUG + docker: compose: @@ -16,6 +20,8 @@ spring: force: true jackson: time-zone: Asia/Seoul + serialization: + write-dates-as-timestamps: false config: import: optional:file:.env datasource: @@ -40,12 +46,22 @@ spring: kakao: client-id: ${Client_ID} redirect-uri: ${REDIRECT_URI} + deep-link: ${DEEP_LINK} data: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} + mongodb: + uri: ${MONGO_URI} + database: ${MONGO_DATABASE} jwt: secret: ${JWT_SECRET} +openai: + model: gpt-4o + secret-key: ${OPEN_AI_KEY} + + +