From cd4ef7a726cf341f0ed1dba93eecddf859684301 Mon Sep 17 00:00:00 2001 From: kimhobeen Date: Mon, 2 Oct 2023 13:35:44 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20feat=20:=20chat=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20#512?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Server/build.gradle | 4 + Server/src/docs/asciidoc/index.adoc | 5 + .../asciidoc/snippets/chat/adminchat.adoc | 56 +++++ .../docs/asciidoc/snippets/chat/docinfo.html | 36 +++ .../docs/asciidoc/snippets/chat/userchat.adoc | 32 +++ .../java/com/server/auth/SecurityConfig.java | 9 +- .../com/server/auth/util/SecurityUtil.java | 35 +++ .../com/server/chat/config/RedisConfig.java | 58 +++++ .../server/chat/config/WebSocketConfig.java | 38 ++++ .../chat/controller/AdminChatController.java | 74 ++++++ .../controller/ChatMessageController.java | 74 ++++++ .../chat/controller/UserChatController.java | 42 ++++ .../com/server/chat/entity/ChatMessage.java | 25 ++ .../java/com/server/chat/entity/ChatRoom.java | 36 +++ .../com/server/chat/entity/MessageType.java | 22 ++ .../server/chat/interceptor/StompHandler.java | 149 ++++++++++++ .../chat/repository/ChatRoomRepository.java | 148 ++++++++++++ .../com/server/chat/service/ChatService.java | 114 ++++++++++ .../com/server/chat/sub/RedisSubscriber.java | 27 +++ .../ChatAccessDeniedException.java | 12 + .../ChatAlreadyAssignedException.java | 12 + .../chatexception/ChatException.java | 11 + .../chatexception/ChatNotValidException.java | 12 + .../global/initailizer/warmup/WarmupApi.java | 1 - .../initailizer/warmup/WarmupController.java | 1 - .../initailizer/warmup/WarmupFilter.java | 1 - .../initailizer/warmup/WarmupState.java | 4 +- Server/src/main/resources/application.yml | 6 + .../auth/controller/AuthControllerTest.java | 1 - .../controller/AdminChatControllerTest.java | 213 ++++++++++++++++++ .../controller/ChatMessageControllerTest.java | 12 + .../controller/UserChatControllerTest.java | 117 ++++++++++ .../global/testhelper/ControllerTest.java | 9 +- .../testhelper/RedisTestContainers.java | 29 +++ .../com/server/module/ModuleServiceTest.java | 1 + .../module/s3/service/AwsModuleTest.java | 5 +- .../search/engine/MySQLSearchEngineTest.java | 1 + 37 files changed, 1420 insertions(+), 12 deletions(-) create mode 100644 Server/src/docs/asciidoc/snippets/chat/adminchat.adoc create mode 100644 Server/src/docs/asciidoc/snippets/chat/docinfo.html create mode 100644 Server/src/docs/asciidoc/snippets/chat/userchat.adoc create mode 100644 Server/src/main/java/com/server/auth/util/SecurityUtil.java create mode 100644 Server/src/main/java/com/server/chat/config/RedisConfig.java create mode 100644 Server/src/main/java/com/server/chat/config/WebSocketConfig.java create mode 100644 Server/src/main/java/com/server/chat/controller/AdminChatController.java create mode 100644 Server/src/main/java/com/server/chat/controller/ChatMessageController.java create mode 100644 Server/src/main/java/com/server/chat/controller/UserChatController.java create mode 100644 Server/src/main/java/com/server/chat/entity/ChatMessage.java create mode 100644 Server/src/main/java/com/server/chat/entity/ChatRoom.java create mode 100644 Server/src/main/java/com/server/chat/entity/MessageType.java create mode 100644 Server/src/main/java/com/server/chat/interceptor/StompHandler.java create mode 100644 Server/src/main/java/com/server/chat/repository/ChatRoomRepository.java create mode 100644 Server/src/main/java/com/server/chat/service/ChatService.java create mode 100644 Server/src/main/java/com/server/chat/sub/RedisSubscriber.java create mode 100644 Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAccessDeniedException.java create mode 100644 Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAlreadyAssignedException.java create mode 100644 Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatException.java create mode 100644 Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatNotValidException.java create mode 100644 Server/src/test/java/com/server/chat/controller/AdminChatControllerTest.java create mode 100644 Server/src/test/java/com/server/chat/controller/ChatMessageControllerTest.java create mode 100644 Server/src/test/java/com/server/chat/controller/UserChatControllerTest.java create mode 100644 Server/src/test/java/com/server/global/testhelper/RedisTestContainers.java diff --git a/Server/build.gradle b/Server/build.gradle index cbe01c53..e90e0531 100644 --- a/Server/build.gradle +++ b/Server/build.gradle @@ -71,6 +71,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-batch' //quartz implementation 'org.springframework.boot:spring-boot-starter-quartz' + //socket (stomp) + implementation 'org.springframework.boot:spring-boot-starter-websocket' + //embedded redis + testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2' } jar { diff --git a/Server/src/docs/asciidoc/index.adoc b/Server/src/docs/asciidoc/index.adoc index 0dedb9a0..e31ac76f 100644 --- a/Server/src/docs/asciidoc/index.adoc +++ b/Server/src/docs/asciidoc/index.adoc @@ -25,6 +25,8 @@ include::overview.adoc[] === Video API * link:snippets/admin/videolist.html[비디오 목록 조회 API, onclick="window.location.href='snippets/admin/videolist.html'"] +=== Chat API +* link:snippets/chat/adminchat.html[관리자용 채팅 API, onclick="window.location.href='snippets/chat/adminchat.html'"] [[API-List]] == 일반 APIs @@ -81,4 +83,7 @@ include::overview.adoc[] === Search API * link:snippets/search/search.html[비디오/채널 통합 검색 API, onclick="window.location.href='snippets/search/search.html'"] +=== Chat API +* link:snippets/chat/userchat.html[사용자 채팅 API, onclick="window.location.href='snippets/chat/userchat.html'"] + diff --git a/Server/src/docs/asciidoc/snippets/chat/adminchat.adoc b/Server/src/docs/asciidoc/snippets/chat/adminchat.adoc new file mode 100644 index 00000000..0edff0d8 --- /dev/null +++ b/Server/src/docs/asciidoc/snippets/chat/adminchat.adoc @@ -0,0 +1,56 @@ +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: +:docinfo: shared-head + +[[AdminChat]] += 관리자용 채팅 API + +== 미할당 채팅방 조회 +=== HTTP Request +include::{snippets}/adminchat/rooms/http-request.adoc[] +==== Request Header +include::{snippets}/adminchat/rooms/request-headers.adoc[] +=== HTTP Response +include::{snippets}/adminchat/rooms/http-response.adoc[] +==== Response Fields +include::{snippets}/adminchat/rooms/response-fields.adoc[] + +== 자신이 속한 채팅방 조회 +=== HTTP Request +include::{snippets}/adminchat/myrooms/http-request.adoc[] +==== Request Header +include::{snippets}/adminchat/myrooms/request-headers.adoc[] +=== HTTP Response +include::{snippets}/adminchat/myrooms/http-response.adoc[] +==== Response Fields +include::{snippets}/adminchat/myrooms/response-fields.adoc[] + +== 채팅방 대화 조회 API +=== HTTP Request +include::{snippets}/adminchat/getmessages/http-request.adoc[] +==== Request Header +include::{snippets}/adminchat/getmessages/request-headers.adoc[] +==== Path Parameters +include::{snippets}/adminchat/getmessages/path-parameters.adoc[] +==== Query Parameters +include::{snippets}/adminchat/getmessages/request-parameters.adoc[] +=== HTTP Response +include::{snippets}/adminchat/getmessages/http-response.adoc[] +==== Response Fields +include::{snippets}/adminchat/getmessages/response-fields.adoc[] + +== 상담 완료 처리 API +=== HTTP Request +include::{snippets}/adminchat/completechat/http-request.adoc[] +==== Request Header +include::{snippets}/adminchat/completechat/request-headers.adoc[] +==== Path Parameters +include::{snippets}/adminchat/completechat/path-parameters.adoc[] +=== HTTP Response +include::{snippets}/adminchat/completechat/http-response.adoc[] + + diff --git a/Server/src/docs/asciidoc/snippets/chat/docinfo.html b/Server/src/docs/asciidoc/snippets/chat/docinfo.html new file mode 100644 index 00000000..ba403f51 --- /dev/null +++ b/Server/src/docs/asciidoc/snippets/chat/docinfo.html @@ -0,0 +1,36 @@ + \ No newline at end of file diff --git a/Server/src/docs/asciidoc/snippets/chat/userchat.adoc b/Server/src/docs/asciidoc/snippets/chat/userchat.adoc new file mode 100644 index 00000000..38280b98 --- /dev/null +++ b/Server/src/docs/asciidoc/snippets/chat/userchat.adoc @@ -0,0 +1,32 @@ +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:sectlinks: +:docinfo: shared-head + +[[UserChat]] += 사용자용 채팅 API + +== 채팅방 대화 조회 API +=== HTTP Request +include::{snippets}/userchat/getmessages/http-request.adoc[] +==== Request Header +include::{snippets}/userchat/getmessages/request-headers.adoc[] +==== Query Parameters +include::{snippets}/userchat/getmessages/request-parameters.adoc[] +=== HTTP Response +include::{snippets}/userchat/getmessages/http-response.adoc[] +==== Response Fields +include::{snippets}/userchat/getmessages/response-fields.adoc[] + +== 상담방 나가기 (삭제) API +=== HTTP Request +include::{snippets}/userchat/exitchat/http-request.adoc[] +==== Request Header +include::{snippets}/userchat/exitchat/request-headers.adoc[] +=== HTTP Response +include::{snippets}/userchat/exitchat/http-response.adoc[] + + diff --git a/Server/src/main/java/com/server/auth/SecurityConfig.java b/Server/src/main/java/com/server/auth/SecurityConfig.java index 52cff373..68daa66e 100644 --- a/Server/src/main/java/com/server/auth/SecurityConfig.java +++ b/Server/src/main/java/com/server/auth/SecurityConfig.java @@ -18,6 +18,7 @@ import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.server.auth.jwt.filter.JwtAuthenticationFilter; @@ -58,7 +59,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .accessDeniedHandler((request, response, accessDeniedException) -> response.sendError(HttpServletResponse.SC_FORBIDDEN)) .authenticationEntryPoint(new MemberAuthenticationEntryPoint()) .and() - .authorizeRequests(getAuthorizeRequests()); + .authorizeRequests(getAuthorizeRequests()) + ; return http.build(); } @@ -71,7 +73,8 @@ public Customizer> getCors() { configuration.setAllowedOrigins(List.of( "http://localhost:3000", "https://www.itprometheus.net", - "https://admin.itprometheus.net")); + "https://admin.itprometheus.net", + "file://", "http://jxy.me")); configuration.addAllowedMethod("*"); configuration.addAllowedHeader("*"); configuration.setAllowCredentials(true); @@ -110,6 +113,8 @@ private Customizer.Expression .antMatchers("/admin/**").hasAnyRole("ADMIN") + .antMatchers("/user/**").hasAnyRole("USER", "ADMIN") + .antMatchers("/auth/**").permitAll() .anyRequest().permitAll(); } diff --git a/Server/src/main/java/com/server/auth/util/SecurityUtil.java b/Server/src/main/java/com/server/auth/util/SecurityUtil.java new file mode 100644 index 00000000..d446ab4d --- /dev/null +++ b/Server/src/main/java/com/server/auth/util/SecurityUtil.java @@ -0,0 +1,35 @@ +package com.server.auth.util; + +import com.server.auth.jwt.service.CustomUserDetails; +import com.server.domain.member.entity.Authority; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.stream.Collectors; + +public class SecurityUtil { + + public static String getEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if(authentication == null || authentication.getPrincipal() == null) { + return "미로그인 사용자"; + } + + CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); + return principal.getUsername(); + } + + public static boolean isAdmin() { + + if(SecurityContextHolder.getContext().getAuthentication() == null) { + return false; + } + + CustomUserDetails principal = (CustomUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return Authority.valueOf(principal.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining())).equals(Authority.ROLE_ADMIN); + } +} diff --git a/Server/src/main/java/com/server/chat/config/RedisConfig.java b/Server/src/main/java/com/server/chat/config/RedisConfig.java new file mode 100644 index 00000000..3135c3cc --- /dev/null +++ b/Server/src/main/java/com/server/chat/config/RedisConfig.java @@ -0,0 +1,58 @@ +package com.server.chat.config; + +import com.server.chat.entity.ChatMessage; +import com.server.chat.sub.RedisSubscriber; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +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.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +@Configuration +public class RedisConfig { + + @Bean + public ChannelTopic channelTopic() { + return new ChannelTopic("chatroom"); + } + + /** + * redis pub/sub 메시지를 처리하는 listener 설정 + */ + @Bean + public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory, + MessageListenerAdapter listenerAdapter, + ChannelTopic channelTopic) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(listenerAdapter, channelTopic); + return container; + } + + @Bean + public MessageListenerAdapter listenerAdapter(RedisSubscriber redisSubscriber) { + return new MessageListenerAdapter(redisSubscriber, "sendMessage"); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + return redisTemplate; + } + + @Bean + public RedisTemplate redisTemplateChatMessage(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(ChatMessage.class)); + return redisTemplate; + } +} diff --git a/Server/src/main/java/com/server/chat/config/WebSocketConfig.java b/Server/src/main/java/com/server/chat/config/WebSocketConfig.java new file mode 100644 index 00000000..d5dda18d --- /dev/null +++ b/Server/src/main/java/com/server/chat/config/WebSocketConfig.java @@ -0,0 +1,38 @@ +package com.server.chat.config; + +import com.server.chat.interceptor.StompHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.*; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + + public WebSocketConfig(StompHandler stompHandler) { + this.stompHandler = stompHandler; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/sub"); + config.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns("*") + .withSockJS() + ; + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompHandler); + } +} diff --git a/Server/src/main/java/com/server/chat/controller/AdminChatController.java b/Server/src/main/java/com/server/chat/controller/AdminChatController.java new file mode 100644 index 00000000..e677bca1 --- /dev/null +++ b/Server/src/main/java/com/server/chat/controller/AdminChatController.java @@ -0,0 +1,74 @@ +package com.server.chat.controller; + +import com.server.auth.util.SecurityUtil; +import com.server.chat.entity.ChatMessage; +import com.server.chat.entity.ChatRoom; +import com.server.chat.service.ChatService; +import com.server.global.reponse.ApiPageResponse; +import com.server.global.reponse.ApiSingleResponse; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/admin/chats") +public class AdminChatController { + + private final ChatService chatService; + + public AdminChatController(ChatService chatService) { + this.chatService = chatService; + } + + //모든 미할당 채팅방 목록 조회 + @GetMapping + public ResponseEntity>> rooms() { + + List chatRooms = chatService.getChatRooms(); + + List chatRoomIds = chatRooms.stream() + .map(ChatRoom::getRoomId) + .collect(Collectors.toList()); + + return ResponseEntity.ok(ApiSingleResponse.ok(chatRoomIds, "미할당 채팅방 목록 조회 성공")); + } + + //자신이 참여한 채팅방 목록 조회 + @GetMapping("/my-rooms") + public ResponseEntity>> myRooms() { + + String email = SecurityUtil.getEmail(); + + List chatRoomIds = chatService.getMyAdminRooms(email); + + return ResponseEntity.ok(ApiSingleResponse.ok(chatRoomIds, "자신이 참여한 채팅방 목록 조회 성공")); + } + + //채팅방 이전 대화 조회 + @GetMapping("/{room-id}") + public ResponseEntity> getMessages( + @PathVariable("room-id") String roomId, + @RequestParam(value = "page", defaultValue = "1") int page + ) { + + String email = SecurityUtil.getEmail(); + Page chatRecord = chatService.getChatRecord(email, roomId, page - 1); + + return ResponseEntity.ok(ApiPageResponse.ok(chatRecord, "채팅 메시지 조회 성공")); + } + + @PatchMapping("/{room-id}") + public ResponseEntity completeChat(@PathVariable("room-id") String roomId) { + + String email = SecurityUtil.getEmail(); + + chatService.completeChat(email, roomId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/Server/src/main/java/com/server/chat/controller/ChatMessageController.java b/Server/src/main/java/com/server/chat/controller/ChatMessageController.java new file mode 100644 index 00000000..b6387fca --- /dev/null +++ b/Server/src/main/java/com/server/chat/controller/ChatMessageController.java @@ -0,0 +1,74 @@ +package com.server.chat.controller; + +import com.server.auth.jwt.service.CustomUserDetails; +import com.server.auth.jwt.service.JwtProvider; +import com.server.auth.util.SecurityUtil; +import com.server.chat.entity.ChatMessage; +import com.server.chat.service.ChatService; +import io.jsonwebtoken.Claims; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import static com.server.auth.util.AuthConstant.CLAIM_AUTHORITY; +import static com.server.auth.util.AuthConstant.CLAIM_ID; + +@RestController +public class ChatMessageController { + + private final ChatService chatService; + private final JwtProvider jwtProvider; + + public ChatMessageController(ChatService chatService, JwtProvider jwtProvider) { + this.chatService = chatService; + this.jwtProvider = jwtProvider; + } + + @MessageMapping("/message") + public void message(@RequestBody ChatMessage message, @Header("Authorization") String token) { + + setAuthenticationToContext(token); + + message.setSender(SecurityUtil.getEmail()); + + if(SecurityUtil.isAdmin()) { + message.setSender("상담원"); + } + + chatService.sendChatMessage(message); + + } + + private void setAuthenticationToContext(String token) { + + Claims claims = jwtProvider.getClaims(token.replace("Bearer ", "")); + + Collection authorities = getRoles(claims); + + CustomUserDetails principal = + new CustomUserDetails(claims.get(CLAIM_ID, Long.class), claims.getSubject(), "", authorities); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private List getRoles(Claims claims) { + return Arrays.stream(claims.get(CLAIM_AUTHORITY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} diff --git a/Server/src/main/java/com/server/chat/controller/UserChatController.java b/Server/src/main/java/com/server/chat/controller/UserChatController.java new file mode 100644 index 00000000..476c8918 --- /dev/null +++ b/Server/src/main/java/com/server/chat/controller/UserChatController.java @@ -0,0 +1,42 @@ +package com.server.chat.controller; + +import com.server.auth.util.SecurityUtil; +import com.server.chat.entity.ChatMessage; +import com.server.chat.service.ChatService; +import com.server.global.reponse.ApiPageResponse; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/user/chats") +public class UserChatController { + + private final ChatService chatService; + + public UserChatController(ChatService chatService) { + this.chatService = chatService; + } + + //자신이 참여한 채팅방 조회 (페이징) + @GetMapping("/my-rooms") + public ResponseEntity> getMessages( + @RequestParam(value = "page", defaultValue = "1") int page + ) { + + String email = SecurityUtil.getEmail(); + Page chatRecord = chatService.getChatRecord(email, email, page - 1); + + return ResponseEntity.ok(ApiPageResponse.ok(chatRecord, "채팅 메시지 조회 성공")); + } + + @DeleteMapping + public ResponseEntity exitChat() { + + String email = SecurityUtil.getEmail(); + + chatService.removeChatRoom(email); + + return ResponseEntity.noContent().build(); + } +} diff --git a/Server/src/main/java/com/server/chat/entity/ChatMessage.java b/Server/src/main/java/com/server/chat/entity/ChatMessage.java new file mode 100644 index 00000000..0d8b209c --- /dev/null +++ b/Server/src/main/java/com/server/chat/entity/ChatMessage.java @@ -0,0 +1,25 @@ +package com.server.chat.entity; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import lombok.*; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessage { + + private String roomId; + private String sender; + private String message; + @JsonSerialize(using = ToStringSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime sendDate; + +} diff --git a/Server/src/main/java/com/server/chat/entity/ChatRoom.java b/Server/src/main/java/com/server/chat/entity/ChatRoom.java new file mode 100644 index 00000000..a1e25457 --- /dev/null +++ b/Server/src/main/java/com/server/chat/entity/ChatRoom.java @@ -0,0 +1,36 @@ +package com.server.chat.entity; + +import lombok.Getter; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +public class ChatRoom implements Serializable { + + private static final long serialVersionUID = 6494678977089006639L; + + private String roomId; + private boolean assigned; + private String adminEmail; + private boolean isCompleted; + + public static ChatRoom create(String email) { + ChatRoom chatRoom = new ChatRoom(); + chatRoom.roomId = email; + chatRoom.assigned = false; + chatRoom.adminEmail = null; + return chatRoom; + } + + public void setAdmin(String email) { + this.assigned = true; + this.adminEmail = email; + } + + public void complete() { + this.isCompleted = true; + } +} diff --git a/Server/src/main/java/com/server/chat/entity/MessageType.java b/Server/src/main/java/com/server/chat/entity/MessageType.java new file mode 100644 index 00000000..2f64c581 --- /dev/null +++ b/Server/src/main/java/com/server/chat/entity/MessageType.java @@ -0,0 +1,22 @@ +package com.server.chat.entity; + +import com.server.global.entity.BaseEnum; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum MessageType implements BaseEnum { + TALK("대화"), + QUIT("퇴장"); + + private final String description; + + @Override + public String getName() { + return name(); + } + + @Override + public String getDescription() { + return this.description; + } +} diff --git a/Server/src/main/java/com/server/chat/interceptor/StompHandler.java b/Server/src/main/java/com/server/chat/interceptor/StompHandler.java new file mode 100644 index 00000000..2aa276d2 --- /dev/null +++ b/Server/src/main/java/com/server/chat/interceptor/StompHandler.java @@ -0,0 +1,149 @@ +package com.server.chat.interceptor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.server.auth.jwt.service.CustomUserDetails; +import com.server.auth.jwt.service.JpaUserDetailsService; +import com.server.auth.jwt.service.JwtProvider; +import com.server.auth.util.SecurityUtil; +import com.server.chat.entity.ChatRoom; +import com.server.chat.repository.ChatRoomRepository; +import com.server.chat.service.ChatService; +import com.server.global.exception.businessexception.chatexception.ChatNotValidException; +import io.jsonwebtoken.Claims; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.context.annotation.Profile; +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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +import static com.server.auth.util.AuthConstant.CLAIM_AUTHORITY; +import static com.server.auth.util.AuthConstant.CLAIM_ID; + +@Slf4j +@Component +public class StompHandler implements ChannelInterceptor { + + private final ChatRoomRepository chatRoomRepository; + private final JpaUserDetailsService jpaUserDetailsService; + private final ChatService chatService; + private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper; + + public StompHandler(ChatRoomRepository chatRoomRepository, JpaUserDetailsService jpaUserDetailsService, ChatService chatService, JwtProvider jwtProvider, ObjectMapper objectMapper) { + this.chatRoomRepository = chatRoomRepository; + this.jpaUserDetailsService = jpaUserDetailsService; + this.chatService = chatService; + this.jwtProvider = jwtProvider; + this.objectMapper = objectMapper; + } + + // websocket을 통해 들어온 요청이 처리 되기전 실행된다. + @SneakyThrows + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if(StompCommand.CONNECT == accessor.getCommand()) { + + String jwtToken = accessor.getFirstNativeHeader("Authorization"); + Claims claims = jwtProvider.getClaims(jwtToken.replace("Bearer ", "")); + setAuthenticationToContext(claims); + MDC.put("email", claims.getSubject()); + String authority = (String) claims.get(CLAIM_AUTHORITY); + chatRoomRepository.setSessionId((String) message.getHeaders().get("simpSessionId"), claims.getSubject() + "," + authority); + } + + if(StompCommand.SUBSCRIBE == accessor.getCommand()) { + + setAuthenticationFrom(message); + + String roomId = getRoomId(Optional.ofNullable((String) message.getHeaders().get("simpDestination")).orElse("InvalidRoomId")); + + if(!SecurityUtil.getEmail().equals(roomId)) { + if(!SecurityUtil.isAdmin()) { + throw new ChatNotValidException(); + } + } + + if(SecurityUtil.isAdmin() && !roomId.equals(SecurityUtil.getEmail())) { + chatService.assignAdmin(SecurityUtil.getEmail(), roomId); + }else { + chatService.createChatRoom(roomId); + } + + + } else if(StompCommand.SEND == accessor.getCommand()) { + + setAuthenticationFrom(message); + + String roomId = objectMapper.readValue((byte[]) message.getPayload(), ChatRoom.class).getRoomId(); + + ChatRoom chatRoom = chatService.getChatRoom(roomId); + + if(!chatRoom.getRoomId().equals(SecurityUtil.getEmail())) { + if(!chatRoom.getAdminEmail().equals(SecurityUtil.getEmail())) { + throw new ChatNotValidException(); + } + } + } + + return message; + } + + private void setAuthenticationFrom(Message message) { + String emailAndAuthority = chatRoomRepository.getEmailFrom((String) message.getHeaders().get("simpSessionId")); + + String[] split = emailAndAuthority.split(","); + + GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(split[1]); + + UserDetails userDetails = new CustomUserDetails(null, split[0], "", Collections.singleton(grantedAuthority)); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + + MDC.put("email", split[1]); + } + + private String getRoomId(String destination) { + int lastIndex = destination.lastIndexOf('/'); + if(lastIndex != -1) { + return destination.substring(lastIndex + 1); + } else { + throw new ChatNotValidException(); + } + } + + private void setAuthenticationToContext(Claims claims) { + + Collection authorities = getRoles(claims); + + CustomUserDetails principal = + new CustomUserDetails(claims.get(CLAIM_ID, Long.class), claims.getSubject(), "", authorities); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private List getRoles(Claims claims) { + return Arrays.stream(claims.get(CLAIM_AUTHORITY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} diff --git a/Server/src/main/java/com/server/chat/repository/ChatRoomRepository.java b/Server/src/main/java/com/server/chat/repository/ChatRoomRepository.java new file mode 100644 index 00000000..a60240df --- /dev/null +++ b/Server/src/main/java/com/server/chat/repository/ChatRoomRepository.java @@ -0,0 +1,148 @@ +package com.server.chat.repository; + +import com.server.chat.entity.ChatMessage; +import com.server.chat.entity.ChatRoom; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Repository +@RequiredArgsConstructor +public class ChatRoomRepository { + // Redis CacheKeys + private static final String CHAT_ROOMS = "CHAT_ROOM"; // 채팅룸 저장 + private static final String ADMIN_ASSIGN = "ADMIN_INFO"; + + @Resource(name = "redisTemplate") + private HashOperations hashOpsChatRoom; + @Resource(name = "redisTemplate") + private HashOperations> hashOpsAdminInfo; + @Resource(name = "redisTemplateChatMessage") + private ZSetOperations zSetOpsChatRecord; + + private final StringRedisTemplate stringRedisTemplate; + + // 모든 채팅방 조회 + public List findNotAssignedRoom() { + List allRooms = hashOpsChatRoom.values(CHAT_ROOMS); + + List notAssignedRooms = new ArrayList<>(); + + for(ChatRoom chatRoom : allRooms) { + if(!chatRoom.isAssigned()) { + notAssignedRooms.add(chatRoom); + } + } + + return notAssignedRooms; + } + + // 특정 채팅방 조회 + public Optional findRoomById(String roomId) { + return Optional.ofNullable(hashOpsChatRoom.get(CHAT_ROOMS, roomId)); + } + + //채팅방 대화 내용 조회 + public Page getChatRecord(String roomId, int page) { + Pageable pageable = PageRequest.of(page, 20); + Long size = zSetOpsChatRecord.size(roomId); + if(size == null || size == 0) { + return Page.empty(); + } + + long start = Math.max(0, size - (page + 1) * 20L); + long end = Math.max(0, size - page * 20L - 1); + + Set messages = zSetOpsChatRecord.range(roomId, start, end); + List listMessages = new ArrayList<>(messages); + + return new PageImpl<>(listMessages, pageable, size); + } + + + //채팅방 대화 + public void addChatRecord(String roomId, ChatMessage chatMessage) { + + LocalDateTime now = LocalDateTime.now(); + chatMessage.setSendDate(now); + double timestamp = now.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + zSetOpsChatRecord.add(roomId, chatMessage, timestamp); + } + + + public ChatRoom createChatRoom(String name) { + ChatRoom chatRoom = ChatRoom.create(name); + hashOpsChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom); + return chatRoom; + } + + public void assignRoom(String adminEmail, ChatRoom chatRoom) { + + if(!hashOpsAdminInfo.hasKey(ADMIN_ASSIGN, adminEmail)) { + HashSet hashSet = new HashSet<>(); + hashSet.add(chatRoom.getRoomId()); + hashOpsAdminInfo.put(ADMIN_ASSIGN, adminEmail, hashSet); + }else { + hashOpsAdminInfo.get(ADMIN_ASSIGN, adminEmail).add(chatRoom.getRoomId()); + } + hashOpsChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom); + } + + public void saveChatRoom(ChatRoom chatRoom) { + hashOpsChatRoom.put(CHAT_ROOMS, chatRoom.getRoomId(), chatRoom); + } + + public void removeAdminChatRoom(String adminEmail, String roomId) { + if(!hashOpsAdminInfo.hasKey(ADMIN_ASSIGN, adminEmail)) { + return; + } + hashOpsAdminInfo.get(ADMIN_ASSIGN, adminEmail).remove(roomId); + } + + public void removeChatRoom(String roomId) { + + ChatRoom chatRoom = hashOpsChatRoom.get(CHAT_ROOMS, roomId); + + if(chatRoom.getAdminEmail() != null) { + removeAdminChatRoom(chatRoom.getAdminEmail(), roomId); + } + + hashOpsChatRoom.delete(CHAT_ROOMS, roomId); + zSetOpsChatRecord.removeRange(roomId, 0, -1); + } + + public HashSet getUserEnterRoomId(String email) { + + if(!hashOpsAdminInfo.hasKey(ADMIN_ASSIGN, email)) { + return new HashSet<>(); + } + + return hashOpsAdminInfo.get(ADMIN_ASSIGN, email); + } + + public void setSessionId(String sessionId, String email) { + + ValueOperations valueOperations = stringRedisTemplate.opsForValue(); + valueOperations.set(sessionId, email); + } + + public String getEmailFrom(String sessionId) { + ValueOperations valueOperations = stringRedisTemplate.opsForValue(); + return valueOperations.get(sessionId); + } +} diff --git a/Server/src/main/java/com/server/chat/service/ChatService.java b/Server/src/main/java/com/server/chat/service/ChatService.java new file mode 100644 index 00000000..52fcd0f4 --- /dev/null +++ b/Server/src/main/java/com/server/chat/service/ChatService.java @@ -0,0 +1,114 @@ +package com.server.chat.service; + +import com.server.auth.util.SecurityUtil; +import com.server.chat.entity.ChatMessage; +import com.server.chat.entity.ChatRoom; +import com.server.chat.entity.MessageType; +import com.server.chat.repository.ChatRoomRepository; +import com.server.global.exception.businessexception.chatexception.ChatAlreadyAssignedException; +import com.server.global.exception.businessexception.chatexception.ChatNotValidException; +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class ChatService { + + private final ChannelTopic channelTopic; + private final RedisTemplate redisTemplate; + private final ChatRoomRepository chatRoomRepository; + + public ChatService(ChannelTopic channelTopic, + RedisTemplate redisTemplate, ChatRoomRepository chatRoomRepository) { + this.channelTopic = channelTopic; + this.redisTemplate = redisTemplate; + this.chatRoomRepository = chatRoomRepository; + } + + public void sendChatMessage(ChatMessage chatMessage) { + + chatRoomRepository.addChatRecord(chatMessage.getRoomId(), chatMessage); + redisTemplate.convertAndSend(channelTopic.getTopic(), chatMessage); + + } + + public List getChatRooms() { + + return chatRoomRepository.findNotAssignedRoom(); + } + + public List getMyAdminRooms(String email) { + + HashSet userEnterRoomId = chatRoomRepository.getUserEnterRoomId(email); + + return new ArrayList<>(userEnterRoomId); + + } + + public void assignAdmin(String adminEmail, String roomId) { + + ChatRoom chatRoom = chatRoomRepository.findRoomById(roomId).orElseThrow(ChatNotValidException::new); + if(chatRoom.isAssigned()) { + if(!chatRoom.getAdminEmail().equals(adminEmail)) { + throw new ChatAlreadyAssignedException(); + } + } else { + chatRoom.setAdmin(adminEmail); + chatRoomRepository.assignRoom(adminEmail, chatRoom); + } + } + + public void createChatRoom(String roomId) { + + chatRoomRepository.findRoomById(roomId).orElseGet(() -> chatRoomRepository.createChatRoom(roomId)); + } + + public ChatRoom getChatRoom(String roomId) { + + return chatRoomRepository.findRoomById(roomId).orElseThrow(ChatNotValidException::new); + } + + public Page getChatRecord(String email, String roomId, int page) { + + ChatRoom chatRoom = chatRoomRepository.findRoomById(roomId).orElseThrow(ChatNotValidException::new); + + if(!chatRoom.getRoomId().equals(email)) { + if(chatRoom.isAssigned()) { + if(!chatRoom.getAdminEmail().equals(email)) { + throw new ChatNotValidException(); + } + }else { + throw new ChatNotValidException(); + } + } + + return chatRoomRepository.getChatRecord(roomId, page); + } + + public void completeChat(String email, String roomId) { + + ChatRoom chatRoom = chatRoomRepository.findRoomById(roomId).orElseThrow(ChatNotValidException::new); + + if(!chatRoom.getRoomId().equals(email)) { + if(!chatRoom.getAdminEmail().equals(email)) { + throw new ChatNotValidException(); + } + } + + chatRoom.complete(); + chatRoomRepository.saveChatRoom(chatRoom); + chatRoomRepository.removeAdminChatRoom(chatRoom.getAdminEmail(), roomId); + } + + public void removeChatRoom(String roomId) { + chatRoomRepository.removeChatRoom(roomId); + } +} diff --git a/Server/src/main/java/com/server/chat/sub/RedisSubscriber.java b/Server/src/main/java/com/server/chat/sub/RedisSubscriber.java new file mode 100644 index 00000000..1eddebdc --- /dev/null +++ b/Server/src/main/java/com/server/chat/sub/RedisSubscriber.java @@ -0,0 +1,27 @@ +package com.server.chat.sub; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.server.chat.entity.ChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +@Slf4j +@RequiredArgsConstructor +@Service +public class RedisSubscriber { + + private final ObjectMapper objectMapper; + private final SimpMessageSendingOperations messagingTemplate; + + public void sendMessage(String message) { + try { + ChatMessage chatMessage = objectMapper.readValue(message, ChatMessage.class); + messagingTemplate.convertAndSend("/sub/chat/room/" + chatMessage.getRoomId(), chatMessage); + } catch (Exception e) { + log.error(e.getMessage()); + } + } +} diff --git a/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAccessDeniedException.java b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAccessDeniedException.java new file mode 100644 index 00000000..1bcd3ec9 --- /dev/null +++ b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAccessDeniedException.java @@ -0,0 +1,12 @@ +package com.server.global.exception.businessexception.chatexception; + +import org.springframework.http.HttpStatus; + +public class ChatAccessDeniedException extends ChatException { + private static final String CODE = "CHAT-403"; + private static final String MESSAGE = "채팅방에 접근할 수 없습니다."; + + public ChatAccessDeniedException() { + super(CODE, HttpStatus.FORBIDDEN, MESSAGE); + } +} diff --git a/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAlreadyAssignedException.java b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAlreadyAssignedException.java new file mode 100644 index 00000000..9505125b --- /dev/null +++ b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatAlreadyAssignedException.java @@ -0,0 +1,12 @@ +package com.server.global.exception.businessexception.chatexception; + +import org.springframework.http.HttpStatus; + +public class ChatAlreadyAssignedException extends ChatException { + private static final String CODE = "CHAT-409"; + private static final String MESSAGE = "이미 채팅이 배정되었습니다."; + + public ChatAlreadyAssignedException() { + super(CODE, HttpStatus.CONFLICT, MESSAGE); + } +} diff --git a/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatException.java b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatException.java new file mode 100644 index 00000000..e3e0417e --- /dev/null +++ b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatException.java @@ -0,0 +1,11 @@ +package com.server.global.exception.businessexception.chatexception; + +import com.server.global.exception.businessexception.BusinessException; +import org.springframework.http.HttpStatus; + +public abstract class ChatException extends BusinessException { + + protected ChatException(String errorCode, HttpStatus httpStatus, String message) { + super(errorCode, httpStatus, message); + } +} diff --git a/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatNotValidException.java b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatNotValidException.java new file mode 100644 index 00000000..5781ff41 --- /dev/null +++ b/Server/src/main/java/com/server/global/exception/businessexception/chatexception/ChatNotValidException.java @@ -0,0 +1,12 @@ +package com.server.global.exception.businessexception.chatexception; + +import org.springframework.http.HttpStatus; + +public class ChatNotValidException extends ChatException { + private static final String CODE = "CHAT-400"; + private static final String MESSAGE = "유효하지 않은 채팅방입니다."; + + public ChatNotValidException() { + super(CODE, HttpStatus.BAD_REQUEST, MESSAGE); + } +} diff --git a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupApi.java b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupApi.java index 6f3994df..7e1447fe 100644 --- a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupApi.java +++ b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupApi.java @@ -26,7 +26,6 @@ import java.util.List; @Component -@Profile("prod") @Slf4j public class WarmupApi implements ApplicationListener { diff --git a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupController.java b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupController.java index 27d941ac..51cd1ec7 100644 --- a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupController.java +++ b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupController.java @@ -1,6 +1,5 @@ package com.server.global.initailizer.warmup; -import org.springframework.http.HttpEntity; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupFilter.java b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupFilter.java index 914bb886..f76714b1 100644 --- a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupFilter.java +++ b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupFilter.java @@ -14,7 +14,6 @@ import java.io.IOException; @Component -@Profile("prod") @Order(Ordered.HIGHEST_PRECEDENCE) public class WarmupFilter implements Filter { diff --git a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupState.java b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupState.java index cc5e8af9..13ad4459 100644 --- a/Server/src/main/java/com/server/global/initailizer/warmup/WarmupState.java +++ b/Server/src/main/java/com/server/global/initailizer/warmup/WarmupState.java @@ -1,11 +1,13 @@ package com.server.global.initailizer.warmup; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class WarmupState { - private boolean isWarmupCompleted = false; + @Value("${warmup.is-completed}") + private boolean isWarmupCompleted; public boolean isWarmupCompleted() { return isWarmupCompleted; diff --git a/Server/src/main/resources/application.yml b/Server/src/main/resources/application.yml index 28a6feff..f3f63308 100644 --- a/Server/src/main/resources/application.yml +++ b/Server/src/main/resources/application.yml @@ -75,6 +75,9 @@ decorator: pem: location: src/main/resources/prometheus.pem +warmup: + is-completed: true + --- spring: config: @@ -99,3 +102,6 @@ decorator: datasource: p6spy: enable-logging: false + +warmup: + is-completed: true diff --git a/Server/src/test/java/com/server/auth/controller/AuthControllerTest.java b/Server/src/test/java/com/server/auth/controller/AuthControllerTest.java index dfe20f57..88b40148 100644 --- a/Server/src/test/java/com/server/auth/controller/AuthControllerTest.java +++ b/Server/src/test/java/com/server/auth/controller/AuthControllerTest.java @@ -72,7 +72,6 @@ @SpringBootTest // 시큐리티를 사용하기 위해서 통합 테스트 사용해야 함 (다른 방법 나중에 찾아보기) @ExtendWith({RestDocumentationExtension.class}) @TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 인스턴스 생명주기 클래스에 맞추기 -@ActiveProfiles("local") public class AuthControllerTest { @MockBean diff --git a/Server/src/test/java/com/server/chat/controller/AdminChatControllerTest.java b/Server/src/test/java/com/server/chat/controller/AdminChatControllerTest.java new file mode 100644 index 00000000..f837ab91 --- /dev/null +++ b/Server/src/test/java/com/server/chat/controller/AdminChatControllerTest.java @@ -0,0 +1,213 @@ +package com.server.chat.controller; + +import com.server.chat.entity.ChatMessage; +import com.server.chat.entity.ChatRoom; +import com.server.global.reponse.ApiPageResponse; +import com.server.global.reponse.ApiSingleResponse; +import com.server.global.testhelper.ControllerTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.server.global.testhelper.RestDocsUtil.pageResponseFields; +import static com.server.global.testhelper.RestDocsUtil.singleResponseFields; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class AdminChatControllerTest extends ControllerTest { + + private final String BASE_URL = "/admin/chats"; + + @Test + @DisplayName("미할당 채팅방 목록 조회") + void rooms() throws Exception { + //given + List chatRooms = createChatRooms(3); + List chatRoomIds = chatRooms.stream() + .map(ChatRoom::getRoomId) + .collect(Collectors.toList()); + + given(chatService.getChatRooms()).willReturn(chatRooms); + + String apiResponse = objectMapper.writeValueAsString(ApiSingleResponse.ok(chatRoomIds, "미할당 채팅방 목록 조회 성공")); + + //when + ResultActions actions = mockMvc.perform(get(BASE_URL) + .header(AUTHORIZATION, TOKEN) + .accept(MediaType.APPLICATION_JSON)); + + //then + actions + .andExpect(status().isOk()) + .andExpect(content().string(apiResponse)) + ; + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰 / 관리자용") + ), + singleResponseFields( + fieldWithPath("data").description("미할당 채팅방 목록") + ) + ) + ); + } + + @Test + @DisplayName("자신이 참여한 채팅방 목록 조회 API") + void myRooms() throws Exception { + //given + List chatRooms = createChatRooms(3); + List chatRoomIds = chatRooms.stream() + .map(ChatRoom::getRoomId) + .collect(Collectors.toList()); + + given(chatService.getMyAdminRooms(anyString())).willReturn(chatRoomIds); + + String apiResponse = objectMapper.writeValueAsString(ApiSingleResponse.ok(chatRoomIds, "자신이 참여한 채팅방 목록 조회 성공")); + + //when + ResultActions actions = mockMvc.perform(get(BASE_URL + "/my-rooms") + .header(AUTHORIZATION, TOKEN) + .accept(MediaType.APPLICATION_JSON)); + + //then + actions + .andExpect(status().isOk()) + .andExpect(content().string(apiResponse)) + ; + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰 / 관리자용") + ), + singleResponseFields( + fieldWithPath("data").description("자신에게 할당된 채팅방 목록") + ) + ) + ); + } + + @Test + @DisplayName("채팅방 이전 대화 조회 API") + void getMessages() throws Exception { + //given + String roomId = "test@gmail.com"; + int size = 3; + int page = 1; + List chatMessages = createChatMessages(size); + Page chatMessagePage = createPage(chatMessages, page, size, 3); + + given(chatService.getChatRecord(anyString(), anyString(), anyInt())).willReturn(chatMessagePage); + + String apiResponse = objectMapper.writeValueAsString(ApiPageResponse.ok(chatMessagePage, "채팅 메시지 조회 성공")); + + //when + ResultActions actions = mockMvc.perform(get(BASE_URL + "/{room-id}", roomId) + .header(AUTHORIZATION, TOKEN) + .param("page", String.valueOf(page)) + .accept(MediaType.APPLICATION_JSON)); + + //then + actions + .andExpect(status().isOk()) + .andExpect(content().string(apiResponse)) + ; + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰 / 관리자용") + ), + pathParameters( + parameterWithName("room-id").description("채팅방 아이디") + ), + requestParameters( + parameterWithName("page").description("페이지 번호 (사이즈는 20 고정)") + ), + pageResponseFields( + fieldWithPath("data").description("채팅 메시지"), + fieldWithPath("data[].roomId").description("채팅방 ID"), + fieldWithPath("data[].sender").description("메시지 보낸 사람"), + fieldWithPath("data[].message").description("메시지"), + fieldWithPath("data[].sendDate").description("보낸 시간") + ) + ) + ); + } + + @Test + @DisplayName("상담 완료 처리 API") + void completeChat() throws Exception { + //given + String roomId = "test@gmail.com"; + + //when + ResultActions actions = mockMvc.perform(patch(BASE_URL + "/{room-id}", roomId) + .header(AUTHORIZATION, TOKEN) + .accept(MediaType.APPLICATION_JSON)); + + //then + actions + .andExpect(status().isNoContent()) + ; + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰 / 관리자용") + ), + pathParameters( + parameterWithName("room-id").description("채팅방 아이디") + ) + ) + ); + } + + private List createChatRooms(int size) { + List chatRooms = new ArrayList<>(); + for(int i = 0; i < size; i++) { + ChatRoom chatRoom = ChatRoom.create("test" + i + "@test.com"); + chatRooms.add(chatRoom); + } + return chatRooms; + } + + private List createChatMessages(int size) { + List chatMessages = new ArrayList<>(); + for(int i = 0; i < size; i++) { + ChatMessage chatMessage = new ChatMessage( + "test@test.com", + "test@test.com", + "this is message" + i, + LocalDateTime.now() + ); + chatMessages.add(chatMessage); + } + return chatMessages; + } +} \ No newline at end of file diff --git a/Server/src/test/java/com/server/chat/controller/ChatMessageControllerTest.java b/Server/src/test/java/com/server/chat/controller/ChatMessageControllerTest.java new file mode 100644 index 00000000..0d484259 --- /dev/null +++ b/Server/src/test/java/com/server/chat/controller/ChatMessageControllerTest.java @@ -0,0 +1,12 @@ +package com.server.chat.controller; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ChatMessageControllerTest { + + @Test + void message() { + } +} \ No newline at end of file diff --git a/Server/src/test/java/com/server/chat/controller/UserChatControllerTest.java b/Server/src/test/java/com/server/chat/controller/UserChatControllerTest.java new file mode 100644 index 00000000..42b9a681 --- /dev/null +++ b/Server/src/test/java/com/server/chat/controller/UserChatControllerTest.java @@ -0,0 +1,117 @@ +package com.server.chat.controller; + +import com.server.chat.entity.ChatMessage; +import com.server.global.reponse.ApiPageResponse; +import com.server.global.testhelper.ControllerTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static com.server.global.testhelper.RestDocsUtil.pageResponseFields; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class UserChatControllerTest extends ControllerTest { + + private final String BASE_URL = "/user/chats"; + + @Test + @DisplayName("자신의 채팅방 대화 조회 API") + void getMessages() throws Exception { + //given + int size = 3; + int page = 1; + List chatMessages = createChatMessages(size); + Page chatMessagePage = createPage(chatMessages, page, size, 3); + + given(chatService.getChatRecord(anyString(), anyString(), anyInt())).willReturn(chatMessagePage); + + String apiResponse = objectMapper.writeValueAsString(ApiPageResponse.ok(chatMessagePage, "채팅 메시지 조회 성공")); + + //when + ResultActions actions = mockMvc.perform(get(BASE_URL + "/my-rooms") + .header(AUTHORIZATION, TOKEN) + .param("page", String.valueOf(page)) + .accept(MediaType.APPLICATION_JSON)); + + //then + actions + .andExpect(status().isOk()) + .andExpect(content().string(apiResponse)) + ; + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ), + requestParameters( + parameterWithName("page").description("페이지 번호 (사이즈는 20 고정)") + ), + pageResponseFields( + fieldWithPath("data").description("채팅 메시지"), + fieldWithPath("data[].roomId").description("채팅방 ID"), + fieldWithPath("data[].sender").description("메시지 보낸 사람"), + fieldWithPath("data[].message").description("메시지"), + fieldWithPath("data[].sendDate").description("보낸 시간") + ) + ) + ); + } + + @Test + @DisplayName("채팅방 삭제 API") + void exitChat() throws Exception { + //given + + //when + ResultActions actions = mockMvc.perform(delete(BASE_URL) + .header(AUTHORIZATION, TOKEN)) + ; + + //then + actions + .andExpect(status().isNoContent()); + + //restDocs + actions.andDo( + documentHandler.document( + requestHeaders( + headerWithName(AUTHORIZATION).description("액세스 토큰") + ) + ) + ); + } + + private List createChatMessages(int size) { + List chatMessages = new ArrayList<>(); + for(int i = 0; i < size; i++) { + ChatMessage chatMessage = new ChatMessage( + "test@test.com", + "test@test.com", + "this is message" + i, + LocalDateTime.now() + ); + chatMessages.add(chatMessage); + } + return chatMessages; + } +} \ No newline at end of file diff --git a/Server/src/test/java/com/server/global/testhelper/ControllerTest.java b/Server/src/test/java/com/server/global/testhelper/ControllerTest.java index 5edee852..81ca01c1 100644 --- a/Server/src/test/java/com/server/global/testhelper/ControllerTest.java +++ b/Server/src/test/java/com/server/global/testhelper/ControllerTest.java @@ -16,6 +16,9 @@ import javax.validation.metadata.ConstraintDescriptor; import javax.validation.metadata.PropertyDescriptor; +import com.server.chat.controller.AdminChatController; +import com.server.chat.controller.UserChatController; +import com.server.chat.service.ChatService; import com.server.domain.adjustment.controller.AdjustmentController; import com.server.domain.adjustment.service.AdjustmentService; import com.server.domain.announcement.controller.AnnouncementController; @@ -95,7 +98,9 @@ SearchController.class, ReportController.class, AdjustmentController.class, - AdminController.class + AdminController.class, + AdminChatController.class, + UserChatController.class }) @ExtendWith({RestDocumentationExtension.class}) @ActiveProfiles("local") @@ -134,6 +139,8 @@ public class ControllerTest { protected WarmupState warmupState; @MockBean protected AdjustmentService adjustmentService; + @MockBean + protected ChatService chatService; // 컨트롤러 테스트에 필요한 것들 @Autowired diff --git a/Server/src/test/java/com/server/global/testhelper/RedisTestContainers.java b/Server/src/test/java/com/server/global/testhelper/RedisTestContainers.java new file mode 100644 index 00000000..66814097 --- /dev/null +++ b/Server/src/test/java/com/server/global/testhelper/RedisTestContainers.java @@ -0,0 +1,29 @@ +package com.server.global.testhelper; + + +import org.junit.jupiter.api.DisplayName; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@DisplayName("Redis Test Containers") +@Profile("default") +@Configuration +public class RedisTestContainers { + + private static final String REDIS_DOCKER_IMAGE = "redis:5.0.3-alpine"; + + static { // (1) + GenericContainer REDIS_CONTAINER = + new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE)) + .withExposedPorts(6379) + .withReuse(true); + + REDIS_CONTAINER.start(); // (2) + + // (3) + System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost()); + System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString()); + } +} diff --git a/Server/src/test/java/com/server/module/ModuleServiceTest.java b/Server/src/test/java/com/server/module/ModuleServiceTest.java index f5915593..4bf5aa17 100644 --- a/Server/src/test/java/com/server/module/ModuleServiceTest.java +++ b/Server/src/test/java/com/server/module/ModuleServiceTest.java @@ -2,6 +2,7 @@ import com.server.domain.channel.respository.ChannelRepository; import com.server.domain.video.repository.VideoRepository; +import com.server.module.redis.service.RedisService; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; diff --git a/Server/src/test/java/com/server/module/s3/service/AwsModuleTest.java b/Server/src/test/java/com/server/module/s3/service/AwsModuleTest.java index 7b33df0e..029c5491 100644 --- a/Server/src/test/java/com/server/module/s3/service/AwsModuleTest.java +++ b/Server/src/test/java/com/server/module/s3/service/AwsModuleTest.java @@ -4,10 +4,7 @@ import com.server.module.s3.service.dto.FileType; import com.server.module.s3.service.dto.ImageType; import org.apache.tomcat.util.http.fileupload.IOUtils; - import org.junit.jupiter.api.DisplayName; - import org.junit.jupiter.api.DynamicTest; - import org.junit.jupiter.api.Test; - import org.junit.jupiter.api.TestFactory; + import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; diff --git a/Server/src/test/java/com/server/search/engine/MySQLSearchEngineTest.java b/Server/src/test/java/com/server/search/engine/MySQLSearchEngineTest.java index 364c9e05..f57cf014 100644 --- a/Server/src/test/java/com/server/search/engine/MySQLSearchEngineTest.java +++ b/Server/src/test/java/com/server/search/engine/MySQLSearchEngineTest.java @@ -15,6 +15,7 @@ import com.server.module.ModuleServiceTest; import org.hibernate.jpa.spi.NativeQueryTupleTransformer; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired;