diff --git a/backend/build.gradle b/backend/build.gradle index 8fde5bd59..60f247a88 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -46,6 +46,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation "io.jsonwebtoken:jjwt:0.9.1" implementation 'javax.xml.bind:jaxb-api:2.3.1' diff --git a/backend/src/main/java/com/ody/eta/annotation/WebSocketAuthMember.java b/backend/src/main/java/com/ody/eta/annotation/WebSocketAuthMember.java new file mode 100644 index 000000000..26966d37d --- /dev/null +++ b/backend/src/main/java/com/ody/eta/annotation/WebSocketAuthMember.java @@ -0,0 +1,12 @@ +package com.ody.eta.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface WebSocketAuthMember { + +} diff --git a/backend/src/main/java/com/ody/eta/argumentresolver/WebSocketArgumentResolver.java b/backend/src/main/java/com/ody/eta/argumentresolver/WebSocketArgumentResolver.java new file mode 100644 index 000000000..9f469cdd7 --- /dev/null +++ b/backend/src/main/java/com/ody/eta/argumentresolver/WebSocketArgumentResolver.java @@ -0,0 +1,37 @@ +package com.ody.eta.argumentresolver; + +import com.ody.auth.service.AuthService; +import com.ody.common.exception.OdyException; +import com.ody.common.exception.OdyUnauthorizedException; +import com.ody.eta.annotation.WebSocketAuthMember; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; + +@Slf4j +@RequiredArgsConstructor +public class WebSocketArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(WebSocketAuthMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String accessToken = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + + try { + return authService.parseAccessToken(accessToken); + } catch (OdyException exception) { + log.warn(exception.getMessage()); + throw new OdyUnauthorizedException("액세스 토큰이 유효하지 않습니다."); + } + } +} diff --git a/backend/src/main/java/com/ody/eta/config/WebSocketConfig.java b/backend/src/main/java/com/ody/eta/config/WebSocketConfig.java new file mode 100644 index 000000000..a3e18bf83 --- /dev/null +++ b/backend/src/main/java/com/ody/eta/config/WebSocketConfig.java @@ -0,0 +1,24 @@ +package com.ody.eta.config; + +import org.springframework.context.annotation.Configuration; +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 +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/connect") + .setAllowedOrigins("*"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/topic"); + registry.setApplicationDestinationPrefixes("/publish"); + } +} diff --git a/backend/src/main/java/com/ody/eta/controller/EtaSocketController.java b/backend/src/main/java/com/ody/eta/controller/EtaSocketController.java new file mode 100644 index 000000000..312114af5 --- /dev/null +++ b/backend/src/main/java/com/ody/eta/controller/EtaSocketController.java @@ -0,0 +1,39 @@ +package com.ody.eta.controller; + +import com.ody.eta.annotation.WebSocketAuthMember; +import com.ody.eta.dto.request.MateEtaRequest; +import com.ody.eta.service.EtaSocketService; +import com.ody.meeting.dto.response.MateEtaResponsesV2; +import com.ody.member.domain.Member; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.SendTo; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class EtaSocketController { + + private final EtaSocketService etaSocketService; + + @MessageMapping("/open/{meetingId}") + public void open(@DestinationVariable Long meetingId) { + log.info("--- websocket open ! - {}", meetingId); + etaSocketService.open(meetingId); + } + + @MessageMapping("/etas/{meetingId}") + @SendTo("/topic/etas/{meetingId}") + public MateEtaResponsesV2 etaUpdate( + @DestinationVariable Long meetingId, + @WebSocketAuthMember Member member, + @Payload MateEtaRequest etaRequest + ) { + log.info("--- etaUpdate 호출 ! - {}, {}, {}", meetingId, member, etaRequest); + return etaSocketService.etaUpdate(meetingId, member, etaRequest); + } +} diff --git a/backend/src/main/java/com/ody/eta/service/EtaSocketService.java b/backend/src/main/java/com/ody/eta/service/EtaSocketService.java new file mode 100644 index 000000000..455191918 --- /dev/null +++ b/backend/src/main/java/com/ody/eta/service/EtaSocketService.java @@ -0,0 +1,74 @@ +package com.ody.eta.service; + +import com.ody.eta.dto.request.MateEtaRequest; +import com.ody.mate.service.MateService; +import com.ody.meeting.domain.Meeting; +import com.ody.meeting.dto.response.MateEtaResponsesV2; +import com.ody.meeting.service.MeetingService; +import com.ody.member.domain.Member; +import com.ody.util.TimeUtil; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EtaSocketService { + + private static final ZoneOffset KST_OFFSET = ZoneOffset.ofHours(9); + private static final Map LATEST_TRIGGER_TIME_CACHE = new ConcurrentHashMap<>(); + private static final Map MEETING_TIME_CACHE = new ConcurrentHashMap<>(); + + private final MeetingService meetingService; + private final MateService mateService; + private final TaskScheduler taskScheduler; + private final SimpMessagingTemplate template; + + public void open(Long meetingId) { + if (!MEETING_TIME_CACHE.containsKey(meetingId)) { + Meeting meeting = meetingService.findById(meetingId); + MEETING_TIME_CACHE.put(meetingId, meeting.getMeetingTime()); + } + scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(1)); + } + + public MateEtaResponsesV2 etaUpdate(Long meetingId, Member member, MateEtaRequest etaRequest) { + if (isOverMeetingTime(meetingId)) { + log.info("--- websocket disconnect ! - {}", meetingId); + template.convertAndSend("/topic/disconnect/" + meetingId, ""); + } else if (isTimeToSchedule(meetingId)) { + scheduleTrigger(meetingId, LocalDateTime.now().plusSeconds(10)); + } + return mateService.findAllMateEtas(etaRequest, meetingId, member); + } + + private boolean isOverMeetingTime(Long meetingId) { + LocalDateTime meetingTime = MEETING_TIME_CACHE.get(meetingId); + LocalDateTime lastTriggerTime = meetingTime.plusMinutes(1L); + return TimeUtil.nowWithTrim().isAfter(lastTriggerTime); + } + + private boolean isTimeToSchedule(Long meetingId) { + LocalDateTime lastTriggerTime = LATEST_TRIGGER_TIME_CACHE.get(meetingId); + Duration duration = Duration.between(lastTriggerTime, LocalDateTime.now()); + return duration.toSeconds() >= 10; + } + + private void scheduleTrigger(Long meetingId, LocalDateTime triggerTime) { + Instant startTime = triggerTime.toInstant(KST_OFFSET); + taskScheduler.schedule( + () -> template.convertAndSend("topic/coordinates/" + meetingId, ""), startTime + ); + LATEST_TRIGGER_TIME_CACHE.put(meetingId, LocalDateTime.now()); + log.info("--- schedule 예약 완료 ! - {}, {}", meetingId, triggerTime); + } +} diff --git a/backend/src/test/java/com/ody/common/websocket/BaseStompTest.java b/backend/src/test/java/com/ody/common/websocket/BaseStompTest.java new file mode 100644 index 000000000..9ea2e5508 --- /dev/null +++ b/backend/src/test/java/com/ody/common/websocket/BaseStompTest.java @@ -0,0 +1,87 @@ +package com.ody.common.websocket; + +import com.ody.common.DatabaseCleaner; +import com.ody.common.TestAuthConfig; +import com.ody.common.TestRouteConfig; +import com.ody.common.config.JpaAuditingConfig; +import com.ody.eta.config.WebSocketConfig; +import com.ody.notification.config.FcmConfig; +import com.ody.notification.service.FcmPushSender; +import com.ody.notification.service.FcmSubscriber; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +@Import({JpaAuditingConfig.class, TestRouteConfig.class, TestAuthConfig.class, WebSocketConfig.class}) +@ExtendWith(SpringExtension.class) +@ActiveProfiles("test") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) +public class BaseStompTest { + + private static final String ENDPOINT = "/connect"; + + @MockBean + private FcmConfig fcmConfig; + + @MockBean + protected FcmSubscriber fcmSubscriber; + + @MockBean + protected FcmPushSender fcmPushSender; + + @Autowired + private DatabaseCleaner databaseCleaner; + + protected StompSession stompSession; + + @LocalServerPort + private int port; + + private final String url; + + private final WebSocketStompClient websocketClient; + + public BaseStompTest() { + this.websocketClient = new WebSocketStompClient(new StandardWebSocketClient()); + this.websocketClient.setMessageConverter(new MappingJackson2MessageConverter()); + this.url = "ws://localhost:"; + } + + @BeforeEach + public void connect() throws ExecutionException, InterruptedException, TimeoutException { + this.stompSession = this.websocketClient + .connect(url + port + ENDPOINT, new StompSessionHandlerAdapter() { + }) + .get(3, TimeUnit.SECONDS); + } + + @BeforeEach + void databaseCleanUp() { + databaseCleaner.cleanUp(); + } + + @AfterEach + public void disconnect() { + if (this.stompSession.isConnected()) { + this.stompSession.disconnect(); + } + } +} diff --git a/backend/src/test/java/com/ody/common/websocket/MessageFrameHandler.java b/backend/src/test/java/com/ody/common/websocket/MessageFrameHandler.java new file mode 100644 index 000000000..b024cb9fb --- /dev/null +++ b/backend/src/test/java/com/ody/common/websocket/MessageFrameHandler.java @@ -0,0 +1,33 @@ +package com.ody.common.websocket; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; + +public class MessageFrameHandler implements StompFrameHandler { + + private final CompletableFuture completableFuture = new CompletableFuture<>(); + + private final Class tClass; + + public MessageFrameHandler(Class tClass) { + this.tClass = tClass; + } + + @Override + public Type getPayloadType(StompHeaders headers) { + return this.tClass; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + if(completableFuture.complete((T)payload)){ + System.out.println("끝남"); + } + } + + public CompletableFuture getCompletableFuture() { + return completableFuture; + } +} diff --git a/backend/src/test/java/com/ody/eta/controller/EtaSocketControllerTest.java b/backend/src/test/java/com/ody/eta/controller/EtaSocketControllerTest.java new file mode 100644 index 000000000..55566da88 --- /dev/null +++ b/backend/src/test/java/com/ody/eta/controller/EtaSocketControllerTest.java @@ -0,0 +1,201 @@ +package com.ody.eta.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.ody.auth.service.AuthService; +import com.ody.common.Fixture; +import com.ody.common.websocket.BaseStompTest; +import com.ody.common.websocket.MessageFrameHandler; +import com.ody.eta.dto.request.MateEtaRequest; +import com.ody.mate.service.MateService; +import com.ody.meeting.domain.Meeting; +import com.ody.meeting.dto.response.MateEtaResponsesV2; +import com.ody.meeting.repository.MeetingRepository; +import com.ody.member.domain.AuthProvider; +import com.ody.member.domain.DeviceToken; +import com.ody.member.domain.Member; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.TaskScheduler; + +class EtaSocketControllerTest extends BaseStompTest { + + @MockBean + private MateService mateService; + + @MockBean + private TaskScheduler taskScheduler; + + @MockBean + private AuthService authService; + + @SpyBean + private SimpMessagingTemplate template; + + @Autowired + private MeetingRepository meetingRepository; + + @DisplayName("/publish/open/{meetingId} 호출 시 1초 뒤에 위치 호출 함수가 예약된다") + @Test + void callEtaMethodWhenStartConnection() throws InterruptedException { + Meeting meeting = generateNotOverdueMeeting(); + + Mockito.when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))) + .thenReturn(null); + this.stompSession.send("/publish/open/" + meeting.getId(), ""); + + /* THEN */ + Thread.sleep(3000); + verify(taskScheduler, times(1)) + .schedule(any(Runnable.class), any(Instant.class)); //TODO: 10 초 내 n번 open이 될때 스케쥴링은 1번만 되는지 + } + + @DisplayName("약속 시간 이후, /publish/etas/{meetingId} 호출 시 disconnect 트리거를 당긴다.") + @Test + void triggerDisconnect() throws InterruptedException, ExecutionException, TimeoutException { + MateEtaResponsesV2 stubResponse = new MateEtaResponsesV2(100L, List.of()); + + Mockito.when(authService.parseAccessToken(any())) + .thenReturn(generateStubMember()); //인증 처리를 위한 stub + + Mockito.when(mateService.findAllMateEtas(any(), any(), any())) + .thenReturn(stubResponse); //응답 stub 처리 + + Mockito.when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))) + .thenReturn(null); + + Meeting notOverdueMeeting = generateNotOverdueMeeting(); + Meeting overdueMeeting = generateOverdueMeeting(); + MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113"); + + MessageFrameHandler handler = new MessageFrameHandler<>(MateEtaResponsesV2.class); + stompSession.subscribe("/topic/etas/" + overdueMeeting.getId(), handler); + Thread.sleep(3000); + + stompSession.send("/publish/open/" + overdueMeeting.getId(), ""); + Thread.sleep(3000); + + stompSession.send("/publish/etas/" + overdueMeeting.getId(), request); + + Thread.sleep(3000); + handler.getCompletableFuture().get(10, TimeUnit.SECONDS); + verify(template, times(1)) + .convertAndSend(eq("/topic/disconnect/" + overdueMeeting.getId()), eq("")); + } + + @DisplayName("호출 한지 10초가 지난 경우, 다시 update 요청을 예약한다.") + @Test + void scheduleTriggerWhenDurationMoreThan10Seconds() + throws InterruptedException, ExecutionException, TimeoutException { + + MateEtaResponsesV2 stubResponse = new MateEtaResponsesV2(100L, List.of()); + + Mockito.when(authService.parseAccessToken(any())) + .thenReturn(generateStubMember()); //인증 처리를 위한 stub + + Mockito.when(mateService.findAllMateEtas(any(), any(), any())) + .thenReturn(stubResponse); //응답 stub 처리 + + Meeting notOverdueMeeting = generateNotOverdueMeeting(); + Meeting overdueMeeting = generateOverdueMeeting(); + MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113"); + + Mockito.when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))) + .thenReturn(null) + .thenReturn(null); + + Thread.sleep(3000); + + stompSession.send("/publish/open/" + notOverdueMeeting.getId(), ""); + Thread.sleep(3000); + + stompSession.send("/publish/etas/" + notOverdueMeeting.getId(), request); + + Thread.sleep(11000); //TODO: 10초전 트리거 타임을 셋팅해두고, 10초 뒤 실제 요청 보냈을 때 update 되는지 확인 + stompSession.send("/publish/etas/" + notOverdueMeeting.getId(), request); + + Thread.sleep(3000); + verify(taskScheduler, times(2)) + .schedule(any(Runnable.class), any(Instant.class)); + } + + @DisplayName("/topic/etas/{meetingId}에 구독한 사람들이 요청을 받는다") + @Test + void subscribe() throws ExecutionException, InterruptedException, TimeoutException { + Meeting notOverdueMeeting = generateNotOverdueMeeting(); + MateEtaRequest request = new MateEtaRequest(false, "37.515298", "127.103113"); + MateEtaResponsesV2 response = new MateEtaResponsesV2(100L, List.of()); + + MessageFrameHandler handler = new MessageFrameHandler<>(MateEtaResponsesV2.class); + stompSession.subscribe("/topic/etas/" + notOverdueMeeting.getId(), handler); + + Thread.sleep(3000); + + Mockito.when(authService.parseAccessToken(any())).thenReturn(generateStubMember()); //인증 처리를 위한 stub + Mockito.when(mateService.findAllMateEtas(any(), any(), any())).thenReturn(response); //응답 stub 처리 + Mockito.when(taskScheduler.schedule(any(Runnable.class), any(Instant.class))).thenReturn(null); + + stompSession.send("/publish/open/" + notOverdueMeeting.getId(), ""); + stompSession.send("/publish/etas/" + notOverdueMeeting.getId(), request); + + Thread.sleep(3000); + + MateEtaResponsesV2 mateEtaResponsesV2 = handler.getCompletableFuture().get(10, TimeUnit.SECONDS); + assertThat(mateEtaResponsesV2.requesterMateId()).isEqualTo(response.requesterMateId()); + } + + private Meeting generateOverdueMeeting() { + LocalDateTime past = LocalDateTime.now().minusMinutes(10L); + + Meeting meeting = new Meeting( + "오디1", + past.toLocalDate(), + past.toLocalTime(), + Fixture.TARGET_LOCATION, + "초대코드1" + ); + + return meetingRepository.save(meeting); + } + + private Meeting generateNotOverdueMeeting() { + LocalDateTime future = LocalDateTime.now().plusMinutes(10L); + + Meeting meeting = new Meeting( + "오디2", + future.toLocalDate(), + future.toLocalTime(), + Fixture.TARGET_LOCATION, + "초대코드2" + ); + + return meetingRepository.save(meeting); + } + + private Member generateStubMember() { + return new Member( + 1L, + new AuthProvider("pid"), + "콜리1", + "imageUrl1", + new DeviceToken("dt1"), + null, + null + ); + } +}