diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt index 8ad7433..b8151b9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/controller/ChatRestController.kt @@ -6,8 +6,7 @@ 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 jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -18,15 +17,20 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "채팅 관련 API") @RestController @RequestMapping("/api/chat") -class ChatRestController( +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") + @Operation(summary = "채팅 전송 API", + description = """ + 채팅 전송 API 입니다. 해당 API 호출 시, 메시지가 전송되고, 채팅 웹소켓에 연결된 유저들에게 메시지가 전송됩니다. + 메시지의 길이는 최대 200글자입니다. + type 유형 : ['CHAT', 'BOT'] + - CHAT (구현O): 일반 채팅 + - BOT (구현X): 챗봇 기능입니다. 본인에게만 웹소켓 메시지가 전송되고, 채팅방을 나갈 시 다시 볼 수 없습니다. + """) + fun sendMessage(@Valid @RequestBody chatMessage: ChatMessageSendDTO) { chatMongoService.saveAndPublish(chatMessage) } 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 index ed0f39d..ee4ad4f 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/dto/websocket/ChatMessageSendDTO.kt @@ -3,12 +3,15 @@ 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 +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) data class ChatMessageSendDTO( // var userId: Long? = null, var type: MessageType? = null, // 메시지 타입 + @field:NotBlank(message = "빈 문자열을 전송할 수 없습니다.") + @field:Size(min = 1, max = 200, message = "메세지의 길이는 1이상 200이하입니다.") var message: String? = null, // 메시지 // var time: LocalDateTime? = null, // 전송 시간 ){ diff --git a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt index 8fb015a..ee8b171 100644 --- a/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/chat/service/ChatMongoService.kt @@ -9,13 +9,12 @@ import busanVibe.busan.domain.chat.repository.ChatMongoRepository import busanVibe.busan.domain.user.data.User 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 busanVibe.busan.global.apiPayload.exception.GeneralException +import org.slf4j.Logger +import org.slf4j.LoggerFactory import org.springframework.data.domain.Pageable 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 @@ -25,29 +24,15 @@ class ChatMongoService( private val redisPublisher: RedisPublisher, private val topic: ChannelTopic, private val userRepository: UserRepository, - private val messagingTemplate: SimpMessagingTemplate ) { + val log: Logger = LoggerFactory.getLogger(ChatMongoService::class.java) + 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, @@ -68,6 +53,7 @@ class ChatMongoService( userId = currentUser.id ) + log.info("[CHAT] /POST/chat/send, 메시지 전송: $chatMessage") redisPublisher.publish(topic, receiveDto) } diff --git a/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt b/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt index a394f2b..c8c72e9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt +++ b/src/main/kotlin/busanVibe/busan/domain/common/dto/InfoType.kt @@ -11,7 +11,7 @@ enum class InfoType( // 명소 SIGHT("명소", "SIGHT", PlaceType.SIGHT), RESTAURANT("식당", "RESTAURANT", PlaceType.RESTAURANT), - CAFE("카페", "CAFE", PlaceType.CAFE), +// CAFE("카페", "CAFE", PlaceType.CAFE), CULTURE("문화시설", "CULTURE", PlaceType.CULTURE), // 축제 FESTIVAL("축제", "FESTIVAL"), diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt index 6e770c9..0fe8974 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt @@ -22,6 +22,7 @@ class FestivalController( ) { @GetMapping + @Operation(summary = "지역축제 목록 조회") fun festivalList( @RequestParam("sort", required = false) sort: FestivalSortType, @RequestParam("status", required = false)status: FestivalStatus diff --git a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt index 67d7083..eb9145d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/enums/PlaceType.kt @@ -14,7 +14,7 @@ enum class PlaceType( ALL("전체", "ALL", "", "", ""), SIGHT("관광지", "SIGHT", "12", "useTime", "restDate"), RESTAURANT("식당", "RESTAURANT", "39", "openTimeFood", "restDateFood"), - CAFE("카페", "CAFE", "00", "openTimeFood", "restDateFood"), +// CAFE("카페", "CAFE", "00", "openTimeFood", "restDateFood"), CULTURE("문화시설", "CULTURE", "14", "useTimeCulture", "restDateCulture") ; diff --git a/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt b/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt index f4fcabc..78b1b47 100644 --- a/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/search/controller/SearchController.kt @@ -5,6 +5,7 @@ import busanVibe.busan.domain.search.dto.SearchResultDTO import busanVibe.busan.domain.search.enums.GeneralSortType import busanVibe.busan.domain.search.service.SearchQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse +import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping @@ -19,10 +20,12 @@ class SearchController( ) { @GetMapping("/search") + @Operation(summary = "검색 API", description = "통합 검색 API 입니다.
[option: Festival] + [sort: CONGESTION] 조합은 안됩니다.") fun searchResult(@RequestParam("option", defaultValue = "ALL") infoType: InfoType, - @RequestParam("sort", defaultValue = "DEFAULT") sort: GeneralSortType): ApiResponse{ + @RequestParam("sort", defaultValue = "DEFAULT") sort: GeneralSortType, + @RequestParam("keyword", required = false, defaultValue = "") keyword: String): ApiResponse{ - val searchResult = searchQueryService.getSearchResult(infoType, sort) + val searchResult = searchQueryService.getSearchResult(infoType, sort, keyword) return ApiResponse.onSuccess(searchResult) } 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 45bea9a..4a099f6 100644 --- a/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/search/service/SearchQueryService.kt @@ -25,7 +25,7 @@ class SearchQueryService( ) { @Transactional(readOnly = true) - fun getSearchResult(infoType: InfoType, sort: GeneralSortType): SearchResultDTO.ListDto { + fun getSearchResult(infoType: InfoType, sort: GeneralSortType, keyword: String): SearchResultDTO.ListDto { val currentUser = AuthService().getCurrentUser() // 축제 + 혼잡도 검색 예외 처리 @@ -36,7 +36,7 @@ class SearchQueryService( // 명소 타입별 조회 val places = when (infoType) { InfoType.ALL -> placeRepository.findAllWithLikesAndOpenTime() - InfoType.CAFE -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.CAFE) +// InfoType.CAFE -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.CAFE) InfoType.RESTAURANT -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.RESTAURANT) InfoType.SIGHT -> placeRepository.findAllWithLikesAndOpenTimeByType(PlaceType.SIGHT) else -> emptyList() @@ -48,13 +48,29 @@ class SearchQueryService( else -> emptyList() } + // 좋아요 정보 조회 val placeLikeList = placeLikeRepository.findLikeByPlace(places) val festivalLikeList = festivalLikeRepository.findLikeByFestival(festivals) - // 엔티티 List로 DTO List 반환 - val resultList = searchUtil.listToSearchDTO(places, festivals, sort, currentUser, placeLikeList, festivalLikeList) + // 엔티티 List로 DTO List 생성 + val infoDtoList = searchUtil.listToSearchDTO(places, festivals, sort, currentUser, placeLikeList, festivalLikeList) + // 키워드 필터링 후 List 생성 + val resultList: MutableList = + if (keyword.isNotEmpty()) { // 키워드가 존재하면, 이름에 키워드가 존재하지 않는 항목들을 제거 + infoDtoList.toMutableList().apply { removeIf { !containKeyword(it, keyword) } } + } else { + infoDtoList.toMutableList() + } + + // 반환 return SearchResultDTO.ListDto(sort.name, resultList) } + private fun containKeyword(infoDto: SearchResultDTO.InfoDto, keyword: String): Boolean { + val name = infoDto.name.replace(" ", "").lowercase() + val key = keyword.replace(" ", "").lowercase() + return name.contains(key) + } + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt index 5d8a74b..88c4ba5 100644 --- a/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt @@ -90,7 +90,7 @@ class TourCommandService( private fun getUseTime(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = when (placeType) { PlaceType.ALL -> introItem?.useTime - PlaceType.CAFE -> introItem?.openTimeFood +// PlaceType.CAFE -> introItem?.openTimeFood PlaceType.RESTAURANT -> introItem?.openTimeFood PlaceType.SIGHT -> introItem?.useTime PlaceType.CULTURE -> introItem?.useTimeCulture @@ -99,7 +99,7 @@ class TourCommandService( private fun getRest(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = when (placeType) { PlaceType.ALL -> introItem?.restDate - PlaceType.CAFE -> introItem?.restDateFood +// PlaceType.CAFE -> introItem?.restDateFood PlaceType.RESTAURANT -> introItem?.restDateFood PlaceType.SIGHT -> introItem?.restDate PlaceType.CULTURE -> introItem?.restDateCulture @@ -108,7 +108,7 @@ class TourCommandService( private fun getCenter(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = when (placeType) { PlaceType.ALL -> introItem?.infoCenter - PlaceType.CAFE -> introItem?.infoCenterFood +// PlaceType.CAFE -> introItem?.infoCenterFood PlaceType.RESTAURANT -> introItem?.infoCenterFood PlaceType.SIGHT -> introItem?.infoCenter PlaceType.CULTURE -> introItem?.infoCenterCulture diff --git a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt index 8aa514e..ea9d895 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt @@ -27,6 +27,7 @@ class UserAuthController ( ){ @GetMapping("/oauth/kakao") + @Operation(hidden = true) fun callBack(@RequestParam("code") code: String): ResponseEntity { val userResponse: UserLoginResponseDTO.LoginDto = userCommandService.loginOrRegisterByKakao(code) val redirectHeader = loginRedirectUtil.getRedirectHeader(userResponse) diff --git a/src/main/kotlin/busanVibe/busan/domain/user/service/UserQueryService.kt b/src/main/kotlin/busanVibe/busan/domain/user/service/UserQueryService.kt index 20d5a12..52dea66 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/service/UserQueryService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/service/UserQueryService.kt @@ -49,7 +49,7 @@ class UserQueryService( // 명소 조회 val places = when(infoType){ InfoType.ALL -> placeRepository.findLikePlace(user) - InfoType.RESTAURANT, InfoType.SIGHT, InfoType.CAFE -> placeRepository.findLikePlaceByType( + InfoType.RESTAURANT, InfoType.SIGHT -> placeRepository.findLikePlaceByType( infoType.placeType?: throw GeneralException(ErrorStatus.SEARCH_INVALID_CONDITION), user ) 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 23c17b6..63453fe 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.http.converter.HttpMessageNotReadableException import org.springframework.stereotype.Controller import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler @@ -51,6 +52,29 @@ class ExceptionAdvice : ResponseEntityExceptionHandler() { return handleExceptionInternalArgs(ex, HttpHeaders.EMPTY, ErrorStatus.BAD_REQUEST, request, errors) } + // 잘못된 body 요청 처리 + override fun handleHttpMessageNotReadable( + e: HttpMessageNotReadableException, + headers: HttpHeaders, + status: HttpStatusCode, + request: WebRequest + ): ResponseEntity? { + val errorMessage = when { + e.message?.contains("Enum") == true -> "요청 값이 올바르지 않습니다. Enum 타입을 확인해주세요." + e.message?.contains("JSON parse error") == true -> "요청 Body의 JSON 형식이 올바르지 않습니다." + e.message?.contains("Required request body is missing") == true -> "요청 Body가 비어있습니다." + else -> "잘못된 요청 본문입니다. 요청 형식을 확인해주세요." + } + + return handleExceptionInternalArgs( + e, + headers, + ErrorStatus.BAD_REQUEST, + request, + mapOf("body" to errorMessage) + ) + } + @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun handleMethodArgumentTypeMismatch( e: MethodArgumentTypeMismatchException, @@ -132,6 +156,9 @@ class ExceptionAdvice : ResponseEntityExceptionHandler() { return super.handleExceptionInternal(e, body, headers, status, request) } + + + private fun handleExceptionInternalArgs( e: Exception, headers: HttpHeaders,