From ca816b4a1b75078f55514f719071214e2dc0e591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B1=B4=EC=9A=B0?= Date: Tue, 25 Nov 2025 19:47:12 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[FEAT]=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../com/debatetimer/config/CorsConfig.java | 27 ++----- .../debatetimer/config/CorsProperties.java | 31 ++++++++ .../sharing/WebSocketAuthMemberResolver.java | 41 ++++++++++ .../config/sharing/WebSocketConfig.java | 38 ++++++++++ .../controller/sharing/SharingController.java | 25 ++++++ .../request/ChairmanSharingRequest.java | 7 ++ .../dto/sharing/request/SharingRequest.java | 9 +++ .../dto/sharing/response/SharingResponse.java | 9 +++ .../event/sharing/RoomSubscribeListener.java | 47 ++++++++++++ .../exception/errorcode/ClientErrorCode.java | 2 + src/main/resources/application-dev.yml | 4 +- src/main/resources/application-prod.yml | 4 +- .../java/com/debatetimer/BaseStompTest.java | 76 +++++++++++++++++++ .../com/debatetimer/MessageFrameHandler.java | 31 ++++++++ ...onfigTest.java => CorsPropertiesTest.java} | 9 +-- .../controller/GlobalControllerTest.java | 2 +- .../sharing/SharingControllerTest.java | 39 ++++++++++ .../sharing/RoomSubscribeListenerTest.java | 33 ++++++++ .../debatetimer/fixture/HeaderGenerator.java | 9 +++ src/test/resources/application.yml | 4 +- 21 files changed, 420 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/debatetimer/config/CorsProperties.java create mode 100644 src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java create mode 100644 src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java create mode 100644 src/main/java/com/debatetimer/controller/sharing/SharingController.java create mode 100644 src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java create mode 100644 src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java create mode 100644 src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java create mode 100644 src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java create mode 100644 src/test/java/com/debatetimer/BaseStompTest.java create mode 100644 src/test/java/com/debatetimer/MessageFrameHandler.java rename src/test/java/com/debatetimer/config/{CorsConfigTest.java => CorsPropertiesTest.java} (84%) create mode 100644 src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java create mode 100644 src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java diff --git a/build.gradle b/build.gradle index a24fcd40..06191f38 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,9 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + // Websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/debatetimer/config/CorsConfig.java b/src/main/java/com/debatetimer/config/CorsConfig.java index 216c15da..12511729 100644 --- a/src/main/java/com/debatetimer/config/CorsConfig.java +++ b/src/main/java/com/debatetimer/config/CorsConfig.java @@ -1,8 +1,7 @@ package com.debatetimer.config; -import com.debatetimer.exception.custom.DTInitializationException; -import com.debatetimer.exception.errorcode.InitializationErrorCode; -import org.springframework.beans.factory.annotation.Value; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -10,30 +9,16 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(CorsProperties.class) public class CorsConfig implements WebMvcConfigurer { - private final String[] corsOrigin; - - public CorsConfig(@Value("${cors.origin}") String[] corsOrigin) { - validate(corsOrigin); - this.corsOrigin = corsOrigin; - } - - private void validate(String[] corsOrigin) { - if (corsOrigin == null || corsOrigin.length == 0) { - throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_EMPTY); - } - for (String origin : corsOrigin) { - if (origin == null || origin.isBlank()) { - throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK); - } - } - } + private final CorsProperties corsProperties; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOriginPatterns(corsOrigin) + .allowedOriginPatterns(corsProperties.getCorsOrigin()) .allowedMethods( HttpMethod.GET.name(), HttpMethod.POST.name(), diff --git a/src/main/java/com/debatetimer/config/CorsProperties.java b/src/main/java/com/debatetimer/config/CorsProperties.java new file mode 100644 index 00000000..8103553b --- /dev/null +++ b/src/main/java/com/debatetimer/config/CorsProperties.java @@ -0,0 +1,31 @@ +package com.debatetimer.config; + +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@Getter +@ConfigurationProperties(prefix = "cors.origin") +public class CorsProperties { + + private final String[] corsOrigin; + + //TODO 머지될 때 dev, prod secret 갱신 필요 + public CorsProperties(String[] corsOrigin) { + validate(corsOrigin); + this.corsOrigin = corsOrigin; + } + + private void validate(String[] corsOrigin) { + if (corsOrigin == null || corsOrigin.length == 0) { + throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_EMPTY); + } + for (String origin : corsOrigin) { + if (origin == null || origin.isBlank()) { + throw new DTInitializationException(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK); + } + } + } +} diff --git a/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java b/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java new file mode 100644 index 00000000..de16ac9f --- /dev/null +++ b/src/main/java/com/debatetimer/config/sharing/WebSocketAuthMemberResolver.java @@ -0,0 +1,41 @@ +package com.debatetimer.config.sharing; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.controller.tool.jwt.AuthManager; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.auth.AuthService; +import lombok.RequiredArgsConstructor; +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; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class WebSocketAuthMemberResolver implements HandlerMethodArgumentResolver { + + private final AuthManager authManager; + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + String token = accessor.getFirstNativeHeader(HttpHeaders.AUTHORIZATION); + + if (token == null) { + throw new DTClientErrorException(ClientErrorCode.UNAUTHORIZED_MEMBER); + } + + String email = authManager.resolveAccessToken(token); + return authService.getMember(email); + } +} + diff --git a/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java new file mode 100644 index 00000000..b2ee31a6 --- /dev/null +++ b/src/main/java/com/debatetimer/config/sharing/WebSocketConfig.java @@ -0,0 +1,38 @@ +package com.debatetimer.config.sharing; + +import com.debatetimer.config.CorsProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +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 +@RequiredArgsConstructor +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final CorsProperties corsProperties; + private final WebSocketAuthMemberResolver webSocketAuthMemberResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(webSocketAuthMemberResolver); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/room", "/chairman"); + registry.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws") + .setAllowedOriginPatterns(corsProperties.getCorsOrigin()) + .withSockJS(); + } +} diff --git a/src/main/java/com/debatetimer/controller/sharing/SharingController.java b/src/main/java/com/debatetimer/controller/sharing/SharingController.java new file mode 100644 index 00000000..edacf8e6 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/sharing/SharingController.java @@ -0,0 +1,25 @@ +package com.debatetimer.controller.sharing; + +import com.debatetimer.controller.auth.AuthMember; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.sharing.request.SharingRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +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.stereotype.Controller; + +@Controller +public class SharingController { + + @MessageMapping("/event/{roomId}") + @SendTo("/room/{roomId}") + public SharingResponse share( + @AuthMember Member member, + @DestinationVariable(value = "roomId") long roomId, + @Payload SharingRequest request + ) { + return new SharingResponse(request.time()); + } +} diff --git a/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java new file mode 100644 index 00000000..ecc5134f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/request/ChairmanSharingRequest.java @@ -0,0 +1,7 @@ +package com.debatetimer.dto.sharing.request; + +public record ChairmanSharingRequest( + long roomId +) { + +} diff --git a/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java new file mode 100644 index 00000000..b6063711 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/request/SharingRequest.java @@ -0,0 +1,9 @@ +package com.debatetimer.dto.sharing.request; + +import java.time.LocalDateTime; + +public record SharingRequest( + LocalDateTime time +) { + +} diff --git a/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java new file mode 100644 index 00000000..704384d1 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/sharing/response/SharingResponse.java @@ -0,0 +1,9 @@ +package com.debatetimer.dto.sharing.response; + +import java.time.LocalDateTime; + +public record SharingResponse( + LocalDateTime time +) { + +} diff --git a/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java new file mode 100644 index 00000000..f2e4b47a --- /dev/null +++ b/src/main/java/com/debatetimer/event/sharing/RoomSubscribeListener.java @@ -0,0 +1,47 @@ +package com.debatetimer.event.sharing; + +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RoomSubscribeListener { + + private static final String AUDIENCE_SUBSCRIBE_PREFIX = "/room/"; + private static final String CHAIRMAN_CHANNEL_PREFIX = "/chairman/"; + + private final SimpMessagingTemplate messagingTemplate; + + @EventListener + public void handleSubscribeEvent(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String destination = accessor.getDestination(); + if (destination == null) { + return; + } + + if (destination.startsWith(AUDIENCE_SUBSCRIBE_PREFIX)) { + long roomId = parseRoomId(destination); + messagingTemplate.convertAndSend(CHAIRMAN_CHANNEL_PREFIX + roomId, new ChairmanSharingRequest(roomId)); + } + } + + private long parseRoomId(String destination) { + try { + String parsedRoomId = destination.substring(AUDIENCE_SUBSCRIBE_PREFIX.length()); + return Long.parseLong(parsedRoomId); + } catch (NumberFormatException exception) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ROOM_ID); + } + } +} + diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 695cbb0c..5bf7d05b 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -53,6 +53,8 @@ public enum ClientErrorCode implements ResponseErrorCode { ALREADY_DONE_POLL(HttpStatus.BAD_REQUEST, "이미 완료된 투표 입니다"), ALREADY_VOTED_PARTICIPANT(HttpStatus.BAD_REQUEST, "이미 참여한 투표자 입니다"), + INVALID_ROOM_ID(HttpStatus.BAD_REQUEST, "잘못된 roomId 값입니다"), + TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."), NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), POLL_NOT_FOUND(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다."), diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b60259e8..46ebc8ed 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -21,7 +21,9 @@ spring: baseline-version: 1 cors: - origin: ${secret.cors.origin} + origin: + cors-origin: + - ${secret.cors.origin} oauth: client_id: ${secret.oauth.client_id} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 208ac91b..cc4de59d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -20,7 +20,9 @@ spring: baseline-version: 1 cors: - origin: ${secret.cors.origin} + origin: + cors-origin: + - ${secret.cors.origin} oauth: client_id: ${secret.oauth.client_id} diff --git a/src/test/java/com/debatetimer/BaseStompTest.java b/src/test/java/com/debatetimer/BaseStompTest.java new file mode 100644 index 00000000..4f0410c0 --- /dev/null +++ b/src/test/java/com/debatetimer/BaseStompTest.java @@ -0,0 +1,76 @@ +package com.debatetimer; + +import com.debatetimer.fixture.HeaderGenerator; +import com.debatetimer.fixture.entity.MemberGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.List; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.sockjs.client.SockJsClient; +import org.springframework.web.socket.sockjs.client.Transport; +import org.springframework.web.socket.sockjs.client.WebSocketTransport; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseStompTest { + + private static final String SOCKET_ENDPOINT = "/ws"; + + protected StompSession stompSession; + + @LocalServerPort + private int port; + + private final String url; + + private final WebSocketStompClient websocketClient; + + @Autowired + protected MemberGenerator memberGenerator; + + @Autowired + protected HeaderGenerator headerGenerator; + + public BaseStompTest() { + List transports = List.of(new WebSocketTransport(new StandardWebSocketClient())); + this.websocketClient = new WebSocketStompClient(new SockJsClient(transports)); + this.websocketClient.setMessageConverter(buildMessageConverter()); + this.url = "ws://localhost:"; + } + + private MessageConverter buildMessageConverter() { + MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.findAndRegisterModules(); + converter.setObjectMapper(objectMapper); + return converter; + } + + @BeforeEach + public void connect() throws ExecutionException, InterruptedException, TimeoutException { + this.stompSession = this.websocketClient + .connectAsync(url + port + SOCKET_ENDPOINT, new StompSessionHandlerAdapter() { + }) + .get(3, TimeUnit.SECONDS); + } + + @AfterEach + public void disconnect() { + if (this.stompSession.isConnected()) { + this.stompSession.disconnect(); + } + } +} diff --git a/src/test/java/com/debatetimer/MessageFrameHandler.java b/src/test/java/com/debatetimer/MessageFrameHandler.java new file mode 100644 index 00000000..d0e60346 --- /dev/null +++ b/src/test/java/com/debatetimer/MessageFrameHandler.java @@ -0,0 +1,31 @@ +package com.debatetimer; + +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 void handleFrame(StompHeaders headers, Object payload) { + if (completableFuture.complete((T) payload)) { + } + } + + @Override + public Type getPayloadType(StompHeaders headers) { + return this.tClass; + } + + public CompletableFuture getCompletableFuture() { + return completableFuture; + } +} diff --git a/src/test/java/com/debatetimer/config/CorsConfigTest.java b/src/test/java/com/debatetimer/config/CorsPropertiesTest.java similarity index 84% rename from src/test/java/com/debatetimer/config/CorsConfigTest.java rename to src/test/java/com/debatetimer/config/CorsPropertiesTest.java index 0011729a..b4889839 100644 --- a/src/test/java/com/debatetimer/config/CorsConfigTest.java +++ b/src/test/java/com/debatetimer/config/CorsPropertiesTest.java @@ -9,21 +9,21 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullAndEmptySource; -class CorsConfigTest { +class CorsPropertiesTest { @Nested class Validate { @Test void 허용된_도메인이_null_일_경우_예외를_발생시칸다() { - assertThatThrownBy(() -> new CorsConfig(null)) + assertThatThrownBy(() -> new CorsProperties(null)) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_EMPTY.getMessage()); } @Test void 허용된_도메인이_빈_배열일_경우_예외를_발생시칸다() { - assertThatThrownBy(() -> new CorsConfig(new String[0])) + assertThatThrownBy(() -> new CorsProperties(new String[0])) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_EMPTY.getMessage()); } @@ -31,10 +31,9 @@ class Validate { @ParameterizedTest @NullAndEmptySource void 허용된_도메인_중에_빈_값이_있을_경우_예외를_발생시킨다(String empty) { - assertThatThrownBy(() -> new CorsConfig(new String[]{empty})) + assertThatThrownBy(() -> new CorsProperties(new String[]{empty})) .isInstanceOf(DTInitializationException.class) .hasMessage(InitializationErrorCode.CORS_ORIGIN_STRING_BLANK.getMessage()); - } } } diff --git a/src/test/java/com/debatetimer/controller/GlobalControllerTest.java b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java index 6c9c041f..4e4cace9 100644 --- a/src/test/java/com/debatetimer/controller/GlobalControllerTest.java +++ b/src/test/java/com/debatetimer/controller/GlobalControllerTest.java @@ -8,7 +8,7 @@ public class GlobalControllerTest extends BaseControllerTest { - @Value("${cors.origin}") + @Value("${cors.origin.cors-origin[0]}") private String corsOrigin; @Nested diff --git a/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java b/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java new file mode 100644 index 00000000..c1362bc5 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/sharing/SharingControllerTest.java @@ -0,0 +1,39 @@ +package com.debatetimer.controller.sharing; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.BaseStompTest; +import com.debatetimer.MessageFrameHandler; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.sharing.request.SharingRequest; +import com.debatetimer.dto.sharing.response.SharingResponse; +import java.time.LocalDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.messaging.simp.stomp.StompHeaders; + +class SharingControllerTest extends BaseStompTest { + + @Nested + class Share { + + @Test + void 사회자가_발생시킨_이벤트를_청중이_공유받는다() throws ExecutionException, InterruptedException, TimeoutException { + long roomId = 1L; + LocalDateTime time = LocalDateTime.now(); + MessageFrameHandler handler = new MessageFrameHandler<>(SharingResponse.class); + Member member = memberGenerator.generate("example@email.com"); + StompHeaders headers = headerGenerator.generateAccessTokenHeader("/app/event/" + roomId, member); + stompSession.subscribe("/room/" + roomId, handler); //청중의 구독 + + stompSession.send(headers, new SharingRequest(time)); //사회자의 이벤트 발생 + + SharingResponse response = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(response.time()).isEqualTo(time); + } + } +} diff --git a/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java new file mode 100644 index 00000000..b8e2bba7 --- /dev/null +++ b/src/test/java/com/debatetimer/event/sharing/RoomSubscribeListenerTest.java @@ -0,0 +1,33 @@ +package com.debatetimer.event.sharing; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.BaseStompTest; +import com.debatetimer.MessageFrameHandler; +import com.debatetimer.dto.sharing.request.ChairmanSharingRequest; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class RoomSubscribeListenerTest extends BaseStompTest { + + @Nested + class SubscribeListener { + + @Test + void 새로운_청중이_공유되면_사회자에게_정보공유_트리거를_발송한다() throws ExecutionException, InterruptedException, TimeoutException { + long roomId = 1L; + MessageFrameHandler handler = new MessageFrameHandler<>( + ChairmanSharingRequest.class); + stompSession.subscribe("/chairman/" + roomId, handler); + + stompSession.subscribe("/room/" + roomId, handler); + + ChairmanSharingRequest sharingRequest = handler.getCompletableFuture() + .get(3L, TimeUnit.SECONDS); + assertThat(sharingRequest).isNotNull(); + } + } +} diff --git a/src/test/java/com/debatetimer/fixture/HeaderGenerator.java b/src/test/java/com/debatetimer/fixture/HeaderGenerator.java index a8a6b8bb..70362980 100644 --- a/src/test/java/com/debatetimer/fixture/HeaderGenerator.java +++ b/src/test/java/com/debatetimer/fixture/HeaderGenerator.java @@ -6,6 +6,7 @@ import io.restassured.http.Header; import io.restassured.http.Headers; import org.springframework.http.HttpHeaders; +import org.springframework.messaging.simp.stomp.StompHeaders; import org.springframework.stereotype.Component; @Component @@ -21,4 +22,12 @@ public Headers generateAccessTokenHeader(Member member) { String accessToken = jwtTokenProvider.createAccessToken(new MemberInfo(member)); return new Headers(new Header(HttpHeaders.AUTHORIZATION, accessToken)); } + + public StompHeaders generateAccessTokenHeader(String destination, Member member) { + String accessToken = jwtTokenProvider.createAccessToken(new MemberInfo(member)); + StompHeaders stompHeaders = new StompHeaders(); + stompHeaders.setDestination(destination); + stompHeaders.add(HttpHeaders.AUTHORIZATION, accessToken); + return stompHeaders; + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index b844ae6c..fa923caa 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -3,7 +3,9 @@ spring: active: test cors: - origin: http://test.debate-timer.com + origin: + cors-origin: + - http://test.debate-timer.com oauth: client_id: oauth_client_id From 563db633c0bbdeae83707f3a1d72968030a3164d Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:28:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[FEAT]=20=EB=AF=B8=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EB=90=9C=20=ED=88=AC=ED=91=9C=20=EC=99=84=EB=A3=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../debatetimer/config/SchedulerConfig.java | 10 +++ .../poll/PollDomainRepository.java | 7 ++ .../repository/poll/PollRepository.java | 18 ++++- .../repository/poll/VoteRepository.java | 8 +- .../scheduler/PollCleanupScheduler.java | 27 +++++++ .../scheduler/PollCleanupSchedulerTest.java | 74 +++++++++++++++++++ 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/debatetimer/config/SchedulerConfig.java create mode 100644 src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java create mode 100644 src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java diff --git a/.gitignore b/.gitignore index 671e0e9f..7d690374 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ out/ ### application-local.yml /src/main/resources/application-local.yml +.serena diff --git a/src/main/java/com/debatetimer/config/SchedulerConfig.java b/src/main/java/com/debatetimer/config/SchedulerConfig.java new file mode 100644 index 00000000..49893796 --- /dev/null +++ b/src/main/java/com/debatetimer/config/SchedulerConfig.java @@ -0,0 +1,10 @@ +package com.debatetimer.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + +} diff --git a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java index e35ceca4..4bf45775 100644 --- a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java @@ -1,8 +1,10 @@ package com.debatetimer.domainrepository.poll; import com.debatetimer.domain.poll.Poll; +import com.debatetimer.domain.poll.PollStatus; import com.debatetimer.entity.poll.PollEntity; import com.debatetimer.repository.poll.PollRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -38,4 +40,9 @@ public Poll finishPoll(long pollId, long memberId) { pollEntity.updateToDone(); return pollEntity.toDomain(); } + + @Transactional + public void updateStatusToDoneForOldPolls(PollStatus pollStatus, LocalDateTime threshold) { + pollRepository.updateStatusToDoneForOldPolls(PollStatus.DONE, pollStatus, threshold); + } } diff --git a/src/main/java/com/debatetimer/repository/poll/PollRepository.java b/src/main/java/com/debatetimer/repository/poll/PollRepository.java index 22c6db56..80b6624f 100644 --- a/src/main/java/com/debatetimer/repository/poll/PollRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/PollRepository.java @@ -1,12 +1,21 @@ package com.debatetimer.repository.poll; +import com.debatetimer.domain.poll.PollStatus; import com.debatetimer.entity.poll.PollEntity; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDateTime; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; -public interface PollRepository extends JpaRepository { +public interface PollRepository extends Repository { + + PollEntity save(PollEntity pollEntity); + + Optional findById(long id); Optional findByIdAndMemberId(long id, long memberId); @@ -19,4 +28,9 @@ default PollEntity getByIdAndMemberId(long id, long memberId) { return findByIdAndMemberId(id, memberId) .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.POLL_NOT_FOUND)); } + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE PollEntity p SET p.status = :doneStatus WHERE p.status = :status AND p.createdAt <= :threshold") + void updateStatusToDoneForOldPolls(@Param("doneStatus") PollStatus doneStatus, @Param("status") PollStatus status, + @Param("threshold") LocalDateTime threshold); } diff --git a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java index 5f211efd..9cf73a36 100644 --- a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java @@ -2,11 +2,15 @@ import com.debatetimer.entity.poll.VoteEntity; import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.Repository; -public interface VoteRepository extends JpaRepository { +public interface VoteRepository extends Repository { + + VoteEntity save(VoteEntity voteEntity); List findAllByPollId(long pollId); boolean existsByPollIdAndParticipateCode(long pollId, String participateCode); + + long count(); } diff --git a/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java b/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java new file mode 100644 index 00000000..9a88ba38 --- /dev/null +++ b/src/main/java/com/debatetimer/scheduler/PollCleanupScheduler.java @@ -0,0 +1,27 @@ +package com.debatetimer.scheduler; + +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domainrepository.poll.PollDomainRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class PollCleanupScheduler { + + private static final int INTERVAL_HOURS = 12; + private static final long INTERVAL_MILLIS = INTERVAL_HOURS * 60 * 60 * 1000L; + static final int TIMEOUT_HOURS = 3; + + private final PollDomainRepository pollDomainRepository; + + @Scheduled(fixedRate = INTERVAL_MILLIS, zone = "Asia/Seoul") + @Transactional + public void cleanupStalePolls() { + LocalDateTime threshold = LocalDateTime.now().minusHours(TIMEOUT_HOURS); + pollDomainRepository.updateStatusToDoneForOldPolls(PollStatus.PROGRESS, threshold); + } +} diff --git a/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java b/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java new file mode 100644 index 00000000..cea12f4d --- /dev/null +++ b/src/test/java/com/debatetimer/scheduler/PollCleanupSchedulerTest.java @@ -0,0 +1,74 @@ +package com.debatetimer.scheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.repository.poll.PollRepository; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class PollCleanupSchedulerTest extends BaseServiceTest { + + @Autowired + private PollRepository pollRepository; + + @Autowired + private PollCleanupScheduler pollCleanupScheduler; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Nested + class CleanupStalePolls { + + @Test + void 생성_후_일정_시간_이상_경과한_진행_상태인_투표를_완료_상태로_변경한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.PROGRESS); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS + 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.DONE); + } + + @Test + void 생성_후_일정_시간_미만_경과한_진행_상태인_투표는_그대로_유지한다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.PROGRESS); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS - 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.PROGRESS); + } + + @Test + void 이미_완료_상태인_투표는_영향받지_않는다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + PollEntity poll = pollEntityGenerator.generate(table, PollStatus.DONE); + updateCreatedAt(poll.getId(), LocalDateTime.now().minusHours(PollCleanupScheduler.TIMEOUT_HOURS + 1)); + + pollCleanupScheduler.cleanupStalePolls(); + + PollStatus status = pollRepository.getById(poll.getId()).getStatus(); + assertThat(status).isEqualTo(PollStatus.DONE); + } + + private void updateCreatedAt(Long pollId, LocalDateTime createdAt) { + jdbcTemplate.update("UPDATE poll SET created_at = ? WHERE id = ?", createdAt, pollId); + } + } +} From dcba2c95ad5578250a18670f7a65641dcbcd9878 Mon Sep 17 00:00:00 2001 From: Chung-an Lee <44027393+leegwichan@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:51:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?[FEAT]=20=EA=B8=B0=EA=B4=80=EB=B3=84=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/organization/Organization.java | 26 +++++++++ .../organization/OrganizationTemplate.java | 17 ++++++ .../OrganizationDomainRepository.java | 39 +++++++++++++ .../organization/OrganizationEntity.java | 46 +++++++++++++++ .../OrganizationTemplateEntity.java | 56 +++++++++++++++++++ .../organization/OrganizationRepository.java | 12 ++++ .../OrganizationTemplateRepository.java | 12 ++++ .../V15__create_organization_template.sql | 26 +++++++++ .../BaseDomainRepositoryTest.java | 8 +++ .../OrganizationDomainRepositoryTest.java | 39 +++++++++++++ .../entity/OrganizationEntityGenerator.java | 22 ++++++++ .../OrganizationTemplateEntityGenerator.java | 24 ++++++++ 12 files changed, 327 insertions(+) create mode 100644 src/main/java/com/debatetimer/domain/organization/Organization.java create mode 100644 src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java create mode 100644 src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java create mode 100644 src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java create mode 100644 src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java create mode 100644 src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java create mode 100644 src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java create mode 100644 src/main/resources/db/migration/V15__create_organization_template.sql create mode 100644 src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java create mode 100644 src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java create mode 100644 src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java diff --git a/src/main/java/com/debatetimer/domain/organization/Organization.java b/src/main/java/com/debatetimer/domain/organization/Organization.java new file mode 100644 index 00000000..23b0b219 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/Organization.java @@ -0,0 +1,26 @@ +package com.debatetimer.domain.organization; + +import java.util.List; +import lombok.Getter; + +@Getter +public class Organization { + + private final Long id; + private final String name; + private final String affiliation; + private final String iconPath; + private final List templates; + + public Organization(Long id, + String name, + String affiliation, + String iconPath, + List templates) { + this.id = id; + this.name = name; + this.affiliation = affiliation; + this.iconPath = iconPath; + this.templates = templates; + } +} diff --git a/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java new file mode 100644 index 00000000..6c920f38 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java @@ -0,0 +1,17 @@ +package com.debatetimer.domain.organization; + +import lombok.Getter; + +@Getter +public class OrganizationTemplate { + + private final Long id; + private final String name; + private final String data; + + public OrganizationTemplate(Long id, String name, String data) { + this.id = id; + this.name = name; + this.data = data; + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java new file mode 100644 index 00000000..f13054e6 --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java @@ -0,0 +1,39 @@ +package com.debatetimer.domainrepository.organization; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import com.debatetimer.repository.organization.OrganizationRepository; +import com.debatetimer.repository.organization.OrganizationTemplateRepository; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrganizationDomainRepository { + + private final OrganizationRepository organizationRepository; + private final OrganizationTemplateRepository organizationTemplateRepository; + + public List findAll() { + Map> idToTemplatesEntity = organizationTemplateRepository.findAll() + .stream() + .collect(groupingBy( + OrganizationTemplateEntity::getOrganizationId, + mapping(OrganizationTemplateEntity::toDomain, toList())) + ); + + return organizationRepository.findAll() + .stream() + .map(entity -> entity.toDomain( + idToTemplatesEntity.getOrDefault(entity.getId(), Collections.emptyList())) + ).toList(); + } +} diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java new file mode 100644 index 00000000..326e289e --- /dev/null +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java @@ -0,0 +1,46 @@ +package com.debatetimer.entity.organization; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "organization") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrganizationEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String name; + + @NotNull + private String affiliation; + + @NotBlank + private String iconPath; + + public OrganizationEntity(String name, String affiliation, String iconPath) { + this.name = name; + this.affiliation = affiliation; + this.iconPath = iconPath; + } + + public Organization toDomain(List templates) { + return new Organization(this.id, this.name, this.affiliation, this.iconPath, templates); + } +} diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java new file mode 100644 index 00000000..96753279 --- /dev/null +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java @@ -0,0 +1,56 @@ +package com.debatetimer.entity.organization; + + +import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "organization_template") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrganizationTemplateEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id") + private OrganizationEntity organization; + + @NotBlank + private String name; + + @NotBlank + @Column(length = 8191) + private String data; + + public OrganizationTemplateEntity(OrganizationEntity organization, String name, String data) { + this.organization = organization; + this.name = name; + this.data = data; + } + + public OrganizationTemplate toDomain() { + return new OrganizationTemplate(this.id, this.name, this.data); + } + + public Long getOrganizationId() { + return this.organization.getId(); + } +} diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java new file mode 100644 index 00000000..5c6ef65a --- /dev/null +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java @@ -0,0 +1,12 @@ +package com.debatetimer.repository.organization; + +import com.debatetimer.entity.organization.OrganizationEntity; +import java.util.List; +import org.springframework.data.repository.Repository; + +public interface OrganizationRepository extends Repository { + + List findAll(); + + OrganizationEntity save(OrganizationEntity organizationEntity); +} diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java new file mode 100644 index 00000000..4c5b7472 --- /dev/null +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java @@ -0,0 +1,12 @@ +package com.debatetimer.repository.organization; + +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import java.util.List; +import org.springframework.data.repository.Repository; + +public interface OrganizationTemplateRepository extends Repository { + + List findAll(); + + OrganizationTemplateEntity save(OrganizationTemplateEntity entity); +} diff --git a/src/main/resources/db/migration/V15__create_organization_template.sql b/src/main/resources/db/migration/V15__create_organization_template.sql new file mode 100644 index 00000000..42ea295c --- /dev/null +++ b/src/main/resources/db/migration/V15__create_organization_template.sql @@ -0,0 +1,26 @@ +create table organization +( + id bigint auto_increment, + name varchar(255) not null, + affiliation varchar(255) not null, + icon_path varchar(255) not null, + created_at timestamp not null DEFAULT CURRENT_TIMESTAMP, + modified_at timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + primary key (id) +); + +create table organization_template +( + id bigint auto_increment, + name varchar(255) not null, + data varchar(8191) not null, + organization_id bigint not null, + created_at timestamp not null DEFAULT CURRENT_TIMESTAMP, + modified_at timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + primary key (id) +); + +alter table organization_template + add constraint organization_template_to_organization + foreign key (organization_id) + references organization (id); diff --git a/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java index 485a7f25..16b60f98 100644 --- a/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/BaseDomainRepositoryTest.java @@ -8,6 +8,8 @@ import com.debatetimer.fixture.entity.CustomizeTableEntityGenerator; import com.debatetimer.fixture.entity.CustomizeTimeBoxEntityGenerator; import com.debatetimer.fixture.entity.MemberGenerator; +import com.debatetimer.fixture.entity.OrganizationEntityGenerator; +import com.debatetimer.fixture.entity.OrganizationTemplateEntityGenerator; import com.debatetimer.fixture.entity.PollEntityGenerator; import com.debatetimer.fixture.entity.VoteEntityGenerator; import com.debatetimer.repository.customize.BellRepository; @@ -49,6 +51,12 @@ public abstract class BaseDomainRepositoryTest { @Autowired protected VoteEntityGenerator voteEntityGenerator; + @Autowired + protected OrganizationEntityGenerator organizationEntityGenerator; + + @Autowired + protected OrganizationTemplateEntityGenerator organizationTemplateEntityGenerator; + @Autowired protected PollRepository pollRepository; diff --git a/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java new file mode 100644 index 00000000..8e28eb07 --- /dev/null +++ b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java @@ -0,0 +1,39 @@ +package com.debatetimer.domainrepository.organization; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domainrepository.BaseDomainRepositoryTest; +import com.debatetimer.entity.organization.OrganizationEntity; +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class OrganizationDomainRepositoryTest extends BaseDomainRepositoryTest { + + @Autowired + private OrganizationDomainRepository organizationDomainRepository; + + @Nested + class FindAll { + + @Test + void 모든_조직_템플릿을_가져온다() { + OrganizationEntity organization1 = organizationEntityGenerator.generate("한앎", "한양대"); + OrganizationEntity organization2 = organizationEntityGenerator.generate("한모름", "양한대"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿1"); + organizationTemplateEntityGenerator.generate(organization1, "템플릿2"); + organizationTemplateEntityGenerator.generate(organization2, "릿플템1"); + + List organizations = organizationDomainRepository.findAll(); + + assertAll( + () -> assertThat(organizations).hasSize(2), + () -> assertThat(organizations.get(0).getTemplates()).hasSize(2), + () -> assertThat(organizations.get(1).getTemplates()).hasSize(1) + ); + } + } +} diff --git a/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java b/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java new file mode 100644 index 00000000..f193aeca --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/entity/OrganizationEntityGenerator.java @@ -0,0 +1,22 @@ +package com.debatetimer.fixture.entity; + +import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.repository.organization.OrganizationRepository; +import org.springframework.stereotype.Component; + +@Component +public class OrganizationEntityGenerator { + + private static final String DEFAULT_ICON_PATH = "/static/icons/default_icon.png"; + + private final OrganizationRepository organizationRepository; + + public OrganizationEntityGenerator(OrganizationRepository organizationRepository) { + this.organizationRepository = organizationRepository; + } + + public OrganizationEntity generate(String name, String affiliation) { + OrganizationEntity organization = new OrganizationEntity(name, affiliation, DEFAULT_ICON_PATH); + return organizationRepository.save(organization); + } +} diff --git a/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java b/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java new file mode 100644 index 00000000..45242cd7 --- /dev/null +++ b/src/test/java/com/debatetimer/fixture/entity/OrganizationTemplateEntityGenerator.java @@ -0,0 +1,24 @@ +package com.debatetimer.fixture.entity; + +import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.entity.organization.OrganizationTemplateEntity; +import com.debatetimer.repository.organization.OrganizationTemplateRepository; +import org.springframework.stereotype.Component; + +@Component +public class OrganizationTemplateEntityGenerator { + + private static final String DEFAULT_TEMPLATE_CONTENT = "eJyrVspMUbIytjDXUcrMS8tXsqpWykvMTVWyUjJWKCtWMFZ427b1TXPj27YFrxcueN3T8HZWj8LbGVPfdM9V0lEqqSwAqXQODQ7x9%2FWMcgUKJaan5qUkAgWB7IKi%2FOKQ1MRcP4iBbzasedOyESienJ%2BHLP56wwygwUDx8sSivMy8dKfUnBwlq7TEnOJUHaW0zLzM4gwkoVqgtYlJOUCN0dVKxSWJeckgMwKC%2FIOBJhQXpKYmZ4RAnPVmXivQzUDRpPwKqJCff5Cvow%2FI5Zkgqw2NDICyYLPzSnNyIMIBqUUgx6EJBRekJmYDHQcTLgbxU4sg3FodJKc4%2B%2FsNFqf4uYaGBIEtQXPNhDdzFkCiFMVNIZ6%2BrvFOjsGuLnB3QazA6TBzkLMx3AX2DIkhtHXOm0WtCq83zHkzbQfdAwpr8qG7i2JrAbdLRw0%3D"; + + private final OrganizationTemplateRepository organizationTemplateRepository; + + public OrganizationTemplateEntityGenerator(OrganizationTemplateRepository organizationTemplateRepository) { + this.organizationTemplateRepository = organizationTemplateRepository; + } + + public OrganizationTemplateEntity generate(OrganizationEntity organization, String name) { + OrganizationTemplateEntity template = + new OrganizationTemplateEntity(organization, name, DEFAULT_TEMPLATE_CONTENT); + return organizationTemplateRepository.save(template); + } +} From 6e6e123de08b36bf16925ccd462b9fda565118ac Mon Sep 17 00:00:00 2001 From: SANGHUN OH <121424793+unifolio0@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:08:37 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[CHORE]=20=EB=AC=B4=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20(#226)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/Dev_CD.yml | 16 +- .github/workflows/Prod_CD.yml | 16 +- nginx/api.dev.debate-timer.com | 34 ++++ nginx/api.prod.debate-timer.com | 34 ++++ scripts/dev/zero-downtime-deploy.sh | 238 +++++++++++++++++++++++++++ scripts/nginx-switch-port.sh | 66 ++++++++ scripts/prod/zero-downtime-deploy.sh | 238 +++++++++++++++++++++++++++ src/main/resources/application.yml | 5 + 8 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 nginx/api.dev.debate-timer.com create mode 100644 nginx/api.prod.debate-timer.com create mode 100644 scripts/dev/zero-downtime-deploy.sh create mode 100644 scripts/nginx-switch-port.sh create mode 100644 scripts/prod/zero-downtime-deploy.sh diff --git a/.github/workflows/Dev_CD.yml b/.github/workflows/Dev_CD.yml index e9a4e830..1aae3280 100644 --- a/.github/workflows/Dev_CD.yml +++ b/.github/workflows/Dev_CD.yml @@ -59,7 +59,7 @@ jobs: uses: actions/download-artifact@v4 with: name: app-artifact - path: ~/app + path: ~/app/staging - name: Download deploy scripts uses: actions/download-artifact@v4 @@ -67,11 +67,17 @@ jobs: name: deploy-scripts path: ~/app/scripts/ - - name: Replace application to latest - run: sudo sh ~/app/scripts/replace-new-version.sh + - name: Setup log directory + run: | + sudo mkdir -p /home/ubuntu/logs + sudo chown -R ubuntu:ubuntu /home/ubuntu/logs + chmod 755 /home/ubuntu/logs + + - name: Make deploy script executable + run: chmod +x ~/app/scripts/zero-downtime-deploy.sh - - name: Health Check - run: sh ~/app/scripts/health-check.sh + - name: Zero Downtime Deployment + run: sh ~/app/scripts/zero-downtime-deploy.sh - name: Send Discord Alert on Failure if: failure() diff --git a/.github/workflows/Prod_CD.yml b/.github/workflows/Prod_CD.yml index cb10ee58..dad56d3a 100644 --- a/.github/workflows/Prod_CD.yml +++ b/.github/workflows/Prod_CD.yml @@ -59,7 +59,7 @@ jobs: uses: actions/download-artifact@v4 with: name: app-artifact - path: ~/app + path: ~/app/staging - name: Download deploy scripts uses: actions/download-artifact@v4 @@ -67,11 +67,17 @@ jobs: name: deploy-scripts path: ~/app/scripts/ - - name: Replace application to latest - run: sudo sh ~/app/scripts/replace-new-version.sh + - name: Setup log directory + run: | + sudo mkdir -p /home/ubuntu/logs + sudo chown -R ubuntu:ubuntu /home/ubuntu/logs + chmod 755 /home/ubuntu/logs + + - name: Make deploy script executable + run: chmod +x ~/app/scripts/zero-downtime-deploy.sh - - name: Health Check - run: sh ~/app/scripts/health-check.sh + - name: Zero Downtime Deployment + run: sh ~/app/scripts/zero-downtime-deploy.sh - name: Send Discord Alert on Failure if: failure() diff --git a/nginx/api.dev.debate-timer.com b/nginx/api.dev.debate-timer.com new file mode 100644 index 00000000..5b775735 --- /dev/null +++ b/nginx/api.dev.debate-timer.com @@ -0,0 +1,34 @@ +upstream debate_timer_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +server { + server_name api.dev.debate-timer.com; + + location / { + proxy_pass http://debate_timer_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/api.dev.debate-timer.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/api.dev.debate-timer.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = api.dev.debate-timer.com) { + return 308 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name api.dev.debate-timer.com; + return 404; # managed by Certbot +} diff --git a/nginx/api.prod.debate-timer.com b/nginx/api.prod.debate-timer.com new file mode 100644 index 00000000..efa873fe --- /dev/null +++ b/nginx/api.prod.debate-timer.com @@ -0,0 +1,34 @@ +upstream debate_timer_backend { + server 127.0.0.1:8080; + keepalive 32; +} + +server { + server_name api.prod.debate-timer.com; + + location / { + proxy_pass http://debate_timer_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/api.prod.debate-timer.com/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/api.prod.debate-timer.com/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot +} + +server { + if ($host = api.prod.debate-timer.com) { + return 308 https://$host$request_uri; + } # managed by Certbot + + listen 80; + listen [::]:80; + server_name api.prod.debate-timer.com; + return 404; # managed by Certbot +} diff --git a/scripts/dev/zero-downtime-deploy.sh b/scripts/dev/zero-downtime-deploy.sh new file mode 100644 index 00000000..992252c3 --- /dev/null +++ b/scripts/dev/zero-downtime-deploy.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +set -e + +APP_DIR="/home/ubuntu/app" +PORT_FILE="$APP_DIR/current_port.txt" +LOG_FILE="$APP_DIR/deploy.log" +BLUE_PORT=8080 +GREEN_PORT=8081 +BLUE_MONITOR_PORT=8083 +GREEN_MONITOR_PORT=8084 +MAX_HEALTH_CHECK_RETRIES=60 +HEALTH_CHECK_INTERVAL=2 +PROFILE="dev" +TIMEZONE="Asia/Seoul" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" | tee -a "$LOG_FILE" +} + +error_exit() { + log "$1" + exit 1 +} + +get_current_port() { + if [ ! -f "$PORT_FILE" ]; then + log "Port file not found. Initializing with default port $BLUE_PORT" + echo "$BLUE_PORT" > "$PORT_FILE" + echo "$BLUE_PORT" + else + cat "$PORT_FILE" + fi +} + +get_inactive_port() { + local current_port=$1 + if [ "$current_port" -eq "$BLUE_PORT" ]; then + echo "$GREEN_PORT" + else + echo "$BLUE_PORT" + fi +} + +get_monitor_port() { + local app_port=$1 + if [ "$app_port" -eq "$BLUE_PORT" ]; then + echo "$BLUE_MONITOR_PORT" + else + echo "$GREEN_MONITOR_PORT" + fi +} + +is_port_in_use() { + local port=$1 + sudo lsof -t -i:$port > /dev/null 2>&1 + return $? +} + +kill_process_on_port() { + local port=$1 + local pid=$(sudo lsof -t -i:$port 2>/dev/null) + + if [ -z "$pid" ]; then + log "No process running on port $port" + return 0 + fi + + log "Sending graceful shutdown signal to process $pid on port $port" + sudo kill -15 "$pid" + + local wait_count=0 + while [ $wait_count -lt 65 ] && is_port_in_use "$port"; do + sleep 1 + wait_count=$((wait_count + 1)) + done + + if is_port_in_use "$port"; then + log "Process didn't stop gracefully, forcing shutdown" + sudo kill -9 "$pid" 2>/dev/null || true + sleep 2 + fi + + log "Process on port $port stopped successfully" +} + +health_check() { + local port=$1 + local monitor_port=$2 + local health_url="http://localhost:$monitor_port/monitoring/health" + + log "Starting health check for port $port (monitor: $monitor_port)" + + local retry=1 + while [ $retry -le $MAX_HEALTH_CHECK_RETRIES ]; do + local status=$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null || echo "000") + + log "Health check attempt $retry/$MAX_HEALTH_CHECK_RETRIES - Status: $status" + + if [ "$status" = "200" ]; then + log "Health check passed!" + return 0 + fi + + sleep $HEALTH_CHECK_INTERVAL + retry=$((retry + 1)) + done + + log "Health check failed after $MAX_HEALTH_CHECK_RETRIES attempts" + return 1 +} + +start_application() { + local port=$1 + local monitor_port=$2 + local staging_jar="$APP_DIR/staging/app.jar" + local jar_file="$APP_DIR/app-$port.jar" + + if [ ! -f "$staging_jar" ]; then + error_exit "No JAR file found in staging directory: $staging_jar" + fi + + log "Copying JAR from staging to $jar_file" + cp "$staging_jar" "$jar_file" + + log "Starting application on port $port with JAR: $jar_file" + + if is_port_in_use "$port"; then + log "Port $port is in use, cleaning up..." + kill_process_on_port "$port" + fi + + sudo nohup java \ + -Dspring.profiles.active=$PROFILE,monitor \ + -Duser.timezone=$TIMEZONE \ + -Dserver.port=$port \ + -Dmanagement.server.port=$monitor_port \ + -Ddd.service=debate-timer \ + -Ddd.env=$PROFILE \ + -jar "$jar_file" > "$APP_DIR/app-$port.log" 2>&1 & + + local pid=$! + log "Application started with PID: $pid" + + sleep 3 + + if ! kill -0 $pid 2>/dev/null; then + error_exit "Application process died immediately after start. Check logs at $APP_DIR/app-$port.log" + fi +} + +switch_nginx_upstream() { + local new_port=$1 + local nginx_conf="/etc/nginx/sites-available/api.dev.debate-timer.com" + local temp_conf="/tmp/api.dev.debate-timer.com.tmp" + local backup_conf="${nginx_conf}.bak" + + if [ ! -f "$nginx_conf" ]; then + error_exit "nginx configuration not found at $nginx_conf" + fi + + log "Switching nginx upstream to port $new_port" + sudo cp "$nginx_conf" "$backup_conf" + + sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$new_port;/" "$nginx_conf" > "$temp_conf" + sudo cp "$temp_conf" "$nginx_conf" + + if ! sudo nginx -t 2>/dev/null; then + log "nginx configuration test failed, rolling back." + sudo cp "$backup_conf" "$nginx_conf" + sudo rm "$backup_conf" + return 1 + fi + + sudo nginx -s reload + log "nginx reloaded successfully" + + sleep 2 + local response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/" 2>/dev/null || echo "000") + if [ "$response" = "000" ] || [ "$response" = "502" ] || [ "$response" = "503" ]; then + log "nginx health check failed after reload (status: $response). Rolling back nginx config." + sudo cp "$backup_conf" "$nginx_conf" + sudo nginx -s reload + sudo rm "$backup_conf" + return 1 + fi + + log "nginx is now routing traffic to port $new_port" + sudo rm "$backup_conf" + return 0 +} + +main() { + local current_port=$(get_current_port) + local new_port=$(get_inactive_port "$current_port") + local new_monitor_port=$(get_monitor_port "$new_port") + + log "Current active port: $current_port" + log "Deploying to port: $new_port" + log "Monitor port: $new_monitor_port" + + log "Step 1/4: Starting new version on port $new_port" + start_application "$new_port" "$new_monitor_port" + + log "Step 2/4: Performing health check" + if ! health_check "$new_port" "$new_monitor_port"; then + log "Deployment failed: Health check did not pass" + log "Rolling back: Stopping new version on port $new_port" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to health check failure" + fi + + log "Step 3/4: Switching nginx to new version" + if ! switch_nginx_upstream "$new_port"; then + log "nginx switch failed, rolling back" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to nginx switch failure" + fi + + log "Step 4/4: Stopping old version on port $current_port" + kill_process_on_port "$current_port" + + local old_jar="$APP_DIR/app-$current_port.jar" + if [ -f "$old_jar" ]; then + log "Removing old JAR file: $old_jar" + rm -f "$old_jar" + fi + + echo "$new_port" > "$PORT_FILE" + log "Updated active port file to $new_port" + + log "Deployment completed successfully!" + log "Active port: $new_port" + log "Inactive port: $current_port" +} + +main "$@" diff --git a/scripts/nginx-switch-port.sh b/scripts/nginx-switch-port.sh new file mode 100644 index 00000000..586f5c67 --- /dev/null +++ b/scripts/nginx-switch-port.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +set -e + +NGINX_CONF="/etc/nginx/sites-available/api.dev.debate-timer.com" +BACKUP_CONF="/etc/nginx/sites-available/api.dev.debate-timer.com.backup" +TEMP_CONF="/tmp/api.dev.debate-timer.com.tmp" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" +} + +if [ -z "$1" ]; then + log "Usage: $0 " + log "Example: $0 8081" + exit 1 +fi + +NEW_PORT=$1 + +if ! [[ "$NEW_PORT" =~ ^[0-9]+$ ]] || [ "$NEW_PORT" -lt 1 ] || [ "$NEW_PORT" -gt 65535 ]; then + log "Invalid port number: $NEW_PORT" + exit 1 +fi + +if [ ! -f "$NGINX_CONF" ]; then + log "nginx configuration not found at $NGINX_CONF" + exit 1 +fi + +log "Backing up current nginx configuration" +sudo cp "$NGINX_CONF" "$BACKUP_CONF" + +log "Updating nginx upstream to port $NEW_PORT" +sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$NEW_PORT;/" "$NGINX_CONF" > "$TEMP_CONF" + +log "Configuration changes:" +diff "$NGINX_CONF" "$TEMP_CONF" || true + +sudo cp "$TEMP_CONF" "$NGINX_CONF" + +log "Testing nginx configuration" +if ! sudo nginx -t 2>&1; then + log "nginx configuration test failed!" + log "Rolling back to previous configuration" + sudo cp "$BACKUP_CONF" "$NGINX_CONF" + exit 1 +fi + +log "Reloading nginx" +sudo nginx -s reload + +sleep 2 +HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/monitoring/health" 2>/dev/null || echo "000") + +if [ "$HEALTH_STATUS" = "200" ]; then + log "nginx successfully switched to port $NEW_PORT" + log "Health check: OK (status $HEALTH_STATUS)" + rm -f "$TEMP_CONF" + exit 0 +else + log "Health check failed after nginx reload (status: $HEALTH_STATUS)" + log "nginx may not be routing to the correct backend" + exit 1 +fi diff --git a/scripts/prod/zero-downtime-deploy.sh b/scripts/prod/zero-downtime-deploy.sh new file mode 100644 index 00000000..c4b41432 --- /dev/null +++ b/scripts/prod/zero-downtime-deploy.sh @@ -0,0 +1,238 @@ +#!/bin/bash + +set -e + +APP_DIR="/home/ubuntu/app" +PORT_FILE="$APP_DIR/current_port.txt" +LOG_FILE="$APP_DIR/deploy.log" +BLUE_PORT=8080 +GREEN_PORT=8081 +BLUE_MONITOR_PORT=8083 +GREEN_MONITOR_PORT=8084 +MAX_HEALTH_CHECK_RETRIES=60 +HEALTH_CHECK_INTERVAL=2 +PROFILE="prod" +TIMEZONE="Asia/Seoul" + +log() { + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + echo "${timestamp} $@" | tee -a "$LOG_FILE" +} + +error_exit() { + log "$1" + exit 1 +} + +get_current_port() { + if [ ! -f "$PORT_FILE" ]; then + log "Port file not found. Initializing with default port $BLUE_PORT" + echo "$BLUE_PORT" > "$PORT_FILE" + echo "$BLUE_PORT" + else + cat "$PORT_FILE" + fi +} + +get_inactive_port() { + local current_port=$1 + if [ "$current_port" -eq "$BLUE_PORT" ]; then + echo "$GREEN_PORT" + else + echo "$BLUE_PORT" + fi +} + +get_monitor_port() { + local app_port=$1 + if [ "$app_port" -eq "$BLUE_PORT" ]; then + echo "$BLUE_MONITOR_PORT" + else + echo "$GREEN_MONITOR_PORT" + fi +} + +is_port_in_use() { + local port=$1 + sudo lsof -t -i:$port > /dev/null 2>&1 + return $? +} + +kill_process_on_port() { + local port=$1 + local pid=$(sudo lsof -t -i:$port 2>/dev/null) + + if [ -z "$pid" ]; then + log "No process running on port $port" + return 0 + fi + + log "Sending graceful shutdown signal to process $pid on port $port" + sudo kill -15 "$pid" + + local wait_count=0 + while [ $wait_count -lt 65 ] && is_port_in_use "$port"; do + sleep 1 + wait_count=$((wait_count + 1)) + done + + if is_port_in_use "$port"; then + log "Process didn't stop gracefully, forcing shutdown" + sudo kill -9 "$pid" 2>/dev/null || true + sleep 2 + fi + + log "Process on port $port stopped successfully" +} + +health_check() { + local port=$1 + local monitor_port=$2 + local health_url="http://localhost:$monitor_port/monitoring/health" + + log "Starting health check for port $port (monitor: $monitor_port)" + + local retry=1 + while [ $retry -le $MAX_HEALTH_CHECK_RETRIES ]; do + local status=$(curl -s -o /dev/null -w "%{http_code}" "$health_url" 2>/dev/null || echo "000") + + log "Health check attempt $retry/$MAX_HEALTH_CHECK_RETRIES - Status: $status" + + if [ "$status" = "200" ]; then + log "Health check passed!" + return 0 + fi + + sleep $HEALTH_CHECK_INTERVAL + retry=$((retry + 1)) + done + + log "Health check failed after $MAX_HEALTH_CHECK_RETRIES attempts" + return 1 +} + +start_application() { + local port=$1 + local monitor_port=$2 + local staging_jar="$APP_DIR/staging/app.jar" + local jar_file="$APP_DIR/app-$port.jar" + + if [ ! -f "$staging_jar" ]; then + error_exit "No JAR file found in staging directory: $staging_jar" + fi + + log "Copying JAR from staging to $jar_file" + cp "$staging_jar" "$jar_file" + + log "Starting application on port $port with JAR: $jar_file" + + if is_port_in_use "$port"; then + log "Port $port is in use, cleaning up..." + kill_process_on_port "$port" + fi + + sudo nohup java \ + -Dspring.profiles.active=$PROFILE,monitor \ + -Duser.timezone=$TIMEZONE \ + -Dserver.port=$port \ + -Dmanagement.server.port=$monitor_port \ + -Ddd.service=debate-timer \ + -Ddd.env=$PROFILE \ + -jar "$jar_file" > "$APP_DIR/app-$port.log" 2>&1 & + + local pid=$! + log "Application started with PID: $pid" + + sleep 3 + + if ! kill -0 $pid 2>/dev/null; then + error_exit "Application process died immediately after start. Check logs at $APP_DIR/app-$port.log" + fi +} + +switch_nginx_upstream() { + local new_port=$1 + local nginx_conf="/etc/nginx/sites-available/api.prod.debate-timer.com" + local temp_conf="/tmp/api.prod.debate-timer.com.tmp" + local backup_conf="${nginx_conf}.bak" + + if [ ! -f "$nginx_conf" ]; then + error_exit "nginx configuration not found at $nginx_conf" + fi + + log "Switching nginx upstream to port $new_port" + sudo cp "$nginx_conf" "$backup_conf" + + sed "s/server 127\.0\.0\.1:[0-9]\+;/server 127.0.0.1:$new_port;/" "$nginx_conf" > "$temp_conf" + sudo cp "$temp_conf" "$nginx_conf" + + if ! sudo nginx -t 2>/dev/null; then + log "nginx configuration test failed, rolling back." + sudo cp "$backup_conf" "$nginx_conf" + sudo rm "$backup_conf" + return 1 + fi + + sudo nginx -s reload + log "nginx reloaded successfully" + + sleep 2 + local response=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost/" 2>/dev/null || echo "000") + if [ "$response" = "000" ] || [ "$response" = "502" ] || [ "$response" = "503" ]; then + log "nginx health check failed after reload (status: $response). Rolling back nginx config." + sudo cp "$backup_conf" "$nginx_conf" + sudo nginx -s reload + sudo rm "$backup_conf" + return 1 + fi + + log "nginx is now routing traffic to port $new_port" + sudo rm "$backup_conf" + return 0 +} + +main() { + local current_port=$(get_current_port) + local new_port=$(get_inactive_port "$current_port") + local new_monitor_port=$(get_monitor_port "$new_port") + + log "Current active port: $current_port" + log "Deploying to port: $new_port" + log "Monitor port: $new_monitor_port" + + log "Step 1/4: Starting new version on port $new_port" + start_application "$new_port" "$new_monitor_port" + + log "Step 2/4: Performing health check" + if ! health_check "$new_port" "$new_monitor_port"; then + log "Deployment failed: Health check did not pass" + log "Rolling back: Stopping new version on port $new_port" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to health check failure" + fi + + log "Step 3/4: Switching nginx to new version" + if ! switch_nginx_upstream "$new_port"; then + log "nginx switch failed, rolling back" + kill_process_on_port "$new_port" + error_exit "Deployment aborted due to nginx switch failure" + fi + + log "Step 4/4: Stopping old version on port $current_port" + kill_process_on_port "$current_port" + + local old_jar="$APP_DIR/app-$current_port.jar" + if [ -f "$old_jar" ]; then + log "Removing old JAR file: $old_jar" + rm -f "$old_jar" + fi + + echo "$new_port" > "$PORT_FILE" + log "Updated active port file to $new_port" + + log "Deployment completed successfully!" + log "Active port: $new_port" + log "Inactive port: $current_port" +} + +main "$@" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 40e92645..8196d857 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,11 @@ spring: profiles: default: local + lifecycle: + timeout-per-shutdown-phase: 60s + +server: + shutdown: graceful springdoc: swagger-ui: From e118bad22705c203dbc1d7d64026091f9bc4aad1 Mon Sep 17 00:00:00 2001 From: Chung-an Lee <44027393+leegwichan@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:10:13 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[FEAT]=20=EA=B8=B0=EA=B4=80=EB=B3=84=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organization/OrganizationController.java | 20 ++++++ .../OrganizationDomainRepository.java | 15 ++-- .../organization/OrganizationResponse.java | 28 ++++++++ .../organization/OrganizationResponses.java | 17 +++++ .../OrganizationTemplateResponse.java | 10 +++ .../organization/OrganizationService.java | 17 +++++ .../static/icon/debate_commission_icon.png | Bin 0 -> 2215 bytes .../resources/static/icon/government_icon.png | Bin 0 -> 5986 bytes .../resources/static/icon/han_alm_icon.png | Bin 0 -> 7459 bytes .../resources/static/icon/hantomak_icon.png | Bin 0 -> 14343 bytes src/main/resources/static/icon/igam_icon.png | Bin 0 -> 12206 bytes .../resources/static/icon/kogito_icon.png | Bin 0 -> 8524 bytes .../static/icon/kondae_time_icon.png | Bin 0 -> 16223 bytes src/main/resources/static/icon/mcu_icon.png | Bin 0 -> 14474 bytes .../resources/static/icon/nogotte_icon.png | Bin 0 -> 12773 bytes .../resources/static/icon/osansi_icon.png | Bin 0 -> 6361 bytes .../static/icon/seobangjeongto_icon.png | Bin 0 -> 11185 bytes .../resources/static/icon/todallae_icon.png | Bin 0 -> 9787 bytes .../resources/static/icon/visual_icon.png | Bin 0 -> 10904 bytes .../static/icon/yuppm_law_track_icon.png | Bin 0 -> 27378 bytes .../controller/BaseControllerTest.java | 8 +++ .../controller/BaseDocumentTest.java | 4 ++ .../controller/GlobalControllerTest.java | 15 ++++ .../java/com/debatetimer/controller/Tag.java | 2 +- .../OrganizationControllerTest.java | 40 +++++++++++ .../OrganizationDocumentTest.java | 67 ++++++++++++++++++ .../debatetimer/service/BaseServiceTest.java | 8 +++ .../organization/OrganizationServiceTest.java | 45 ++++++++++++ 28 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/debatetimer/controller/organization/OrganizationController.java create mode 100644 src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java create mode 100644 src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java create mode 100644 src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java create mode 100644 src/main/java/com/debatetimer/service/organization/OrganizationService.java create mode 100644 src/main/resources/static/icon/debate_commission_icon.png create mode 100644 src/main/resources/static/icon/government_icon.png create mode 100644 src/main/resources/static/icon/han_alm_icon.png create mode 100644 src/main/resources/static/icon/hantomak_icon.png create mode 100644 src/main/resources/static/icon/igam_icon.png create mode 100644 src/main/resources/static/icon/kogito_icon.png create mode 100644 src/main/resources/static/icon/kondae_time_icon.png create mode 100644 src/main/resources/static/icon/mcu_icon.png create mode 100644 src/main/resources/static/icon/nogotte_icon.png create mode 100644 src/main/resources/static/icon/osansi_icon.png create mode 100644 src/main/resources/static/icon/seobangjeongto_icon.png create mode 100644 src/main/resources/static/icon/todallae_icon.png create mode 100644 src/main/resources/static/icon/visual_icon.png create mode 100644 src/main/resources/static/icon/yuppm_law_track_icon.png create mode 100644 src/test/java/com/debatetimer/controller/organization/OrganizationControllerTest.java create mode 100644 src/test/java/com/debatetimer/controller/organization/OrganizationDocumentTest.java create mode 100644 src/test/java/com/debatetimer/service/organization/OrganizationServiceTest.java diff --git a/src/main/java/com/debatetimer/controller/organization/OrganizationController.java b/src/main/java/com/debatetimer/controller/organization/OrganizationController.java new file mode 100644 index 00000000..46efeae6 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/organization/OrganizationController.java @@ -0,0 +1,20 @@ +package com.debatetimer.controller.organization; + +import com.debatetimer.dto.organization.OrganizationResponses; +import com.debatetimer.service.organization.OrganizationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class OrganizationController { + + private final OrganizationService organizationService; + + @GetMapping("/api/organizations/templates") + public ResponseEntity getOrganizationTemplates() { + return ResponseEntity.ok(organizationService.findAll()); + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java index f13054e6..917de8cc 100644 --- a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java @@ -1,9 +1,5 @@ package com.debatetimer.domainrepository.organization; -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.mapping; -import static java.util.stream.Collectors.toList; - import com.debatetimer.domain.organization.Organization; import com.debatetimer.domain.organization.OrganizationTemplate; import com.debatetimer.entity.organization.OrganizationTemplateEntity; @@ -12,6 +8,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -25,15 +22,15 @@ public class OrganizationDomainRepository { public List findAll() { Map> idToTemplatesEntity = organizationTemplateRepository.findAll() .stream() - .collect(groupingBy( + .collect(Collectors.groupingBy( OrganizationTemplateEntity::getOrganizationId, - mapping(OrganizationTemplateEntity::toDomain, toList())) - ); + Collectors.mapping(OrganizationTemplateEntity::toDomain, Collectors.toList()) + )); return organizationRepository.findAll() .stream() .map(entity -> entity.toDomain( - idToTemplatesEntity.getOrDefault(entity.getId(), Collections.emptyList())) - ).toList(); + idToTemplatesEntity.getOrDefault(entity.getId(), Collections.emptyList()) + )).toList(); } } diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java b/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java new file mode 100644 index 00000000..59bcfa90 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationResponse.java @@ -0,0 +1,28 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; +import java.util.List; + +public record OrganizationResponse( + String organization, + String affiliation, + String iconPath, + List templates +) { + + public OrganizationResponse(Organization organization) { + this( + organization.getName(), + organization.getAffiliation(), + organization.getIconPath(), + toTemplatesResponse(organization.getTemplates()) + ); + } + + private static List toTemplatesResponse(List templates) { + return templates.stream() + .map(OrganizationTemplateResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java b/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java new file mode 100644 index 00000000..f28ca43f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationResponses.java @@ -0,0 +1,17 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.Organization; +import java.util.List; + +public record OrganizationResponses(List organizations) { + + public static OrganizationResponses from(List organizations) { + return new OrganizationResponses(toOrganizationsResponse(organizations)); + } + + private static List toOrganizationsResponse(List organizations) { + return organizations.stream() + .map(OrganizationResponse::new) + .toList(); + } +} diff --git a/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java b/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java new file mode 100644 index 00000000..9c6add1f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/organization/OrganizationTemplateResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.organization; + +import com.debatetimer.domain.organization.OrganizationTemplate; + +public record OrganizationTemplateResponse(String name, String data) { + + public OrganizationTemplateResponse(OrganizationTemplate template) { + this(template.getName(), template.getData()); + } +} diff --git a/src/main/java/com/debatetimer/service/organization/OrganizationService.java b/src/main/java/com/debatetimer/service/organization/OrganizationService.java new file mode 100644 index 00000000..ab07e4b5 --- /dev/null +++ b/src/main/java/com/debatetimer/service/organization/OrganizationService.java @@ -0,0 +1,17 @@ +package com.debatetimer.service.organization; + +import com.debatetimer.domainrepository.organization.OrganizationDomainRepository; +import com.debatetimer.dto.organization.OrganizationResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrganizationService { + + private final OrganizationDomainRepository organizationDomainRepository; + + public OrganizationResponses findAll() { + return OrganizationResponses.from(organizationDomainRepository.findAll()); + } +} diff --git a/src/main/resources/static/icon/debate_commission_icon.png b/src/main/resources/static/icon/debate_commission_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a16e8e8abd20c8e7b0fbfea66aea63a11d6f6a4a GIT binary patch literal 2215 zcmV;Y2w3-tP) zv_-V77DJ64#%N=QX|%2qzpSgP&Xg}^Seb=Jmod`3YQLMAUyhpwXJ9GKGKDbx4)=)X zVo%*aQF=;W`!mEF)Xju29 zK;T5w(B%uJ5W@wGN|!tS*l@7rM-u~MNNbmgg@cB>cQoVfht9xE1i*_Nio*@W#!uiE zYj3AJB_n*wUy!5aBZ;_4Pp4>-AW|F-M{nuHH&$ShQ7kMJd}Mto?tf+s1OKD3C>rd5 z;0CJN`|xOcM}`>XNKO5N>s%#|Ck3D4cW55WDyJ)=^!AkE+8`y3Z~f~K9Ig%I0u|T^ zrx)n!smpZA@A8%HP6(b;ugc+6w+@YVAZ^dr1}PGOvSnb*UQ#5I5ssD*uaq6Lon_TG z$GPcdxvLcK*OSlh4G#`uVf{%iTwZg27DgglkE9qETil+9qqHgB4%5X0OsAE_ui^X;ngxf!cqj4F=5 z9GSRh)5tq7Bd?Lq^Zrw@!TBm+UMm^sxf+rC4?g$KsF(7){DGq{uqAhH9KJ;Qd^N4F zE!Y~L&^)POJ!#(QD?fnim=p=y12;(y6Fh+H9P<*?@dSks9G9J{ zmS;sceOhN#DoU}9(yLcoln$KQlg?sgakb;Z)7jE;HP z=$R+_jG#BCf5opG4G;NvgFn49mQ4gD-iAJ&K%_$_g4pbf*85c9Hd+{-rtW?7zzn4? z3ns>6Ch%hA8l1&aln_B_VbT&Mp(+(_G+WW0l>3_mX`1KCGe+t5=I6W^x-E{;b%|7Y zUdV{IlZZJ?nmI~!{aMBry54o zx`=5yUitgPjY-VSt5zWrwk|V`Axvg;Zanx`-Ul;oQ9#Zi^lrEOH&2o8f}h^zA3)o! zn_+Pxk~(zjf9*;6SMjNg7ZSqHF1mG>OvW*M9*Gl_t#Yd~l4R@aYH%4A+*d4c*8hFALcT9l=t9Ne+> z8AO8?>Ov|M4V}fYW75Wi^2w3DOXE!Wtxt0@jiPjyGW#Xdo(>{Fk2|=Y)R4I%ArB_T zJ=pS_=fKC@mLoTVKAiae0lYh0ieSV=xo{P6J$=Oz8W(b(=lJllKD zf)s}B&)$#dXbUM$t_$&$D(F764Oua~ti*R$RV^=vc5jTQTIHQi;#QQmI+uKCbJK&# zSXyg2nMZZwINCbTfxlfsnZ=pAFsZIu6~;eLtib<=10+*=0?Ql<9(&K&eIY`TiN)6z zGlH^e@HmqQM*o5*y9bcbjw_@n=-l;Dtlx4HBNsU^?F5sXCTf=t;fJmD$ZAv&e*S=C z%}*Y2zJ{?Zt&|bCsa)Pn=7W@*snk}@#PWn{hORE&{Nuf`w_y*`M>Y5T^>bKu`$3Fe zFo=`sHZD-QgyGl|D{=I9=P^lW!I3YIX=De~JUPu==jc098Di;#dDakg1#u21ZB}3d zG)lZPeD;rr$Ob-xv0l@H6#1cqKrHwak@Du$#)}sQwx1F>`+}Z6F<`kHO8MDGNkuPq z-t;w0x|B>&;c<*ANws-ppH{O41earr$I=eTGaU)J_2cGNY}6Cy76^Ok~% z`Q9HQgHf>8O+lR&uQmMq}T^PuuT6 z+Bxf1jfK+Q>l9OfE^%2j4-~+Z1``p<}EdBrh002ovPDHLkV1gA#JB$DT literal 0 HcmV?d00001 diff --git a/src/main/resources/static/icon/government_icon.png b/src/main/resources/static/icon/government_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..27c93db6b55f4d8bc8495d6564f2b5659cbea55a GIT binary patch literal 5986 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

U1O-QuFqOAw^^-a zeX4_+GL#x%HZo83d(LY)Pt1h{|X#Jqi_Fj&Ou7&Sk zsl{qrD1{y}rpREo;p*ds6=avSQU1&)ZqT`?`JH?$e60lrbgG78hKO5$6E+=9MZ;Hi z;cEOHg!*Wu_c_AWYH@_H#n!%|99S#%MUiHE*uws~jX-dap0*{kMj9oLvOudA4bk9< z)`)mwJf^($ovf;YQV$^IbZo_oFsCtPv5KmxacF}5aGgYlkOhRCC`nwAM)C^r@=FcS zzGFv>eQoi1Sk7gclbnH_ zY^7XWbvFG|aQ|%CsuA#h_(BuRT5%Re={cOBPK#BW<8kCx8d}%(LlhOZLYCT6DNX*? z6D?N9Dp2d=9G(UCB4Ux`L2*&Byf;D1<_!@R8H3@IR$4pzJc+IkK$qCK+pFgW)H{9=D zHu~Sz`>PSqt={SV-<=QD7nu9Mw?=#7CfN@!<}@J+`M9DWSM+{H+qcPz)S=XP<>wdC za^y-$$BQ*B^H!XJCv&49Mon2osK=Z+zIQ!5Nt`GY`-fTiaz&`5q*SW%%&0i3tZp1G?lQO3_IeECiNUx8o;g42LBC%@qaU>ppA8$_E zi8V`(*t8F^ck0p3-Ztu8Vp-<7@X!)0U;4H=JJ(6uyVr}2*nD27;7otRcs!D4<2S~~ zZ+YXJytF&!o)b4{6En1Mao14UGhp(tW@r>0C~u&3;dTaQes>t(ecl82m>cIUJ%g;X z?_%(jO<1?rXd+q-_yKvO&sWSIgk~+L zp!eX8Xx=b_z*s9`)CV7LmJ1*)#R|OLbFp;sYv#~O(b`sR-k4BYbaWQ}%cb&v=1LQJ zv2NA&Z>~D`^Z0>x6R#^zPu!}0kgtOiqa%p8Z`KmE3N8X%i&X;_dM}$Zs{So z#8f8w+_Y>M(sQKOsC?YzdOn&x7OUhyKf-=#7!Fh>}5LQ`gK#? zzF!|WGUwS2w-e))zLU4AljwnZ53RgnCV&I1OlnC8NP;oBeYq z^3itOKl3gYZoGg`zWoJGtjyw$6e8T6$<>)H_9YKyn*`{9D3Z}7bUqy8Kz#rUTp4tq~M;;laQK}0-CWbrG2j1Yl)rL~liXpwUekX$Ft1;`l)DL*;h%2NoWIHb z&(B$^=yE*=J*5eSliuy45$NqAvEb6Jmr=iF5Z13ei{qO|qfT`{v>COENxTSydep`J zw0x}DaYbJ9^_%4lWy-z1s4#CfsvCw*O(P|3e)V($5{^$n7QMGqzj=s?4MPS`SR3ZM z+SSNSmYQ+!L;_B-lMp9XEf<4RC1mRr9#)DuvtF`9*RHR5w$GOb{i)l6h)sh( zzlqp)O1rsl@#Zt?TBC<{kA1dPWphUQ9rdLPc~HCBRvd^MA$M{ad*-&ys^F7X z+TodYQE+1upUhCbOd@p;Nz9Z7xfnILIeK=ig&I`?P&v#84P(O*ckw>5ng7J{3R7+o znl^R8+&L4>fuYs4PxX9z-JOK3J?#$irwk{yzjJk+^gxf;G4JkN^vai2{S@=c4STdx z=3k)}=LRP?4o+MT!H^a0PHByVJryWzS9Lm#;--m-83v;JqFkBW#Y@3`=KK# zDi<_bZ6s?ctFoSqhe6z!%g0R?8F}q4n|yO>9=beL7w?Q{g^o|pkZbF{ojnRGHlN3t zBRA#U(5%FMdZq$Fdl?IhWSA=CC@IilK=zUU^bpedx%4GPfEntmJ*rj!!B^1_oJ{7XlHG7(&q#HR22_NIB(7NCEPpws3e+!KtONg{cL90D%AOk3Xt7PR^+%@&KL$`3|as~=W*~?2nSP)b? zb%jAx;OgyFR`q+(#r~Ov{368KJF^kaZf4Z0Yz=VCf3STs^QSl1l8QRTMwQ; zv*URnBNb(L)BXG2_Uj4%+gk-A1_ak^*yZ!y{kje9)g?;fvPJVL9oyD!-l|!3b$GBV zsi1shXJ#TfIUSGEv++>eyMP!>+|jm(or9pPM97LOh&)+reA&K*vVa5!`Lde{Mo>@? z`(rO8rxf7m(Rky&1E)@%J9BPYY4OFqu%(gmJB0s_Cis7`m8}UhgUAuAZ zK+3%vn@y$%f&nr8>RA6LlluR;aSUO;I-M%a-LFZgkGC$++soD4%~hvkJ3xz(NGK>Z zW#yLS(b#)e5EUC(QLv$?s33|cz4y9( z&p&f00ZXC@=KbFH&*R0ld&9fD|+eBWT%i(a;w@)vdm7Di?fl?|rK!FO-cPj1cCbTx*uNQQ!AF_6UMFh;XI7@jUa&dGau6IWMP1sOAge}K672Z%D^(dBJT z19x_3G7dycFFl?Lvze_p*6X?=_dpa1&%8k1!DzSyjfCV-CL>Qc7(^wPwo3^KF|0Hr zpFGyeJ~#=XpX}J{hC4+@2A?!up0<|c)yY$<87?=& zp<{=u3Yp}5UW2q-BCY}CX(cEQPk_kU0DMbb6rPBI`MfqrUwI35Tm6uGTt3VCNoBuor~?dD#{`RxIdViPi6ouBWbEf7eQSs5+@&t{FeJf8eh#=udjOBC6e zR@yeUuar`Wldzs~3vNyZBP-44s7e(O8fZh@uRhA7vY_Ydgworo;Av_gdD3NQG!VmV zu@5hPus`>Vc0aYbp?6pEPS>e_!Dpb)q7jq{u@U?2U%ogMmpSc@+U3k}Zqk%ss0tO} z@G2guE?2;MLl+_nT$De|gidQKNMm!6vHBi6DBI(Gkonb_Y=KytFDsXd1RDH&2E0$2 z1Y%Q^(xM^Jwi6w#GctKj_y{;dcfHZRyKoEs6wJi_cEHsz}PtfL?cdNS{6Ca@3J z(SRbS3_Kkn7!7j0@)j~j$lo1}DEmz}r)Ff^RGjE*O;XPDlJ25Lk+SMGgUH~xAd{H4pF|y6 zfVawUP``^c_|@nDJ+&~9Y~MZTm)=OA`Dhm%+QV%i`1 z20GD68P+byU{HE3ee~md>$<+hI8IX9%|itzUewY|Y7#A$_@MMo+I!9Bd!I$_#>&g^tiq^B3+lH69>JAjC zrTApCNtl*A?K13k_XH#R9a-R!TtgL6u8z7+GQszZsWMw25bg<{m|qe19$aO25$3X&Tbui?(GNA33zg8v&Wg3A?hU_GN;040u#^zGj#2s8hhku9>RarAG3^ zBCdvm7&29XU#NKIDspLV6<~H@=g7m1NWQAO%QC!?PBXXEag&<&Arzl(d4kxb-7@DMd zDd$+sYs1a_xhaF2b!#;upMp{T5cM)dac|r0pm#;2YPC3o*IYw#pC52_w^!ZP&{?=I(GXXzvOvN*v81V&xUvBa{3s(HUH+{@ z;4Z&w`G=!bnvG3)=Hpx9b^nv>eILz)s>#^d6CPc^LrH!Lo?Y96-sAltmF1#5KNp#O zBcQxl&L)5KFMr2#B-T57l#J2$Vfeb<`P*XLbT-(yFwqLw?d{1%J@rG>`Ta#iy$e@h2&%w zR3z*FSk30?Y9MDv6f~T~B;RPmO&EDf*Zfxv#pS&h_ma!wB+>$}qTtsYoAq5Gd65hI zfepBCh6OU`Fz7Y8Zd=t)waKB}D@Wc}k|$YQRVj%MW?x3q^h>b*r4z(D_LolW@{_!| zc-tr^7niQK~I^=09yEE7POAS)<=!-75-{_|ZVJiCZ}y7oBqvj=(%-;Y+q z7Q=7mF*t1M4^@HWPv4;?pSUFSl|IkIo8hNnx4$o^{Cox{WBa|YPVef?NXoLBnlEg_ zNu7Tk=}W>;bn!KG+uL*0Jf(Hb{%Sc95y$YXsFAT+o>mO!v%_G!#h0Y{<%nD9KQn*% zbCG^WJFa2>hKTb$0frcgzHw*K9Q1811I5@_Ybg`=YxUY zhM*uT4iV>9KugyIrp8U#zCSEP99ENrb~+@Zg?DQD5>9O{Gu(jnT}reB#+s7nIUJs@ z2%3$I$wUZ|e?P1cOt4M_DGFGV5UVF&CBwssgwm=JwqH=KM1;MGckiwSf;7)|j+0;p6< ze3z#{X+b&?9$tX3xgMMB6%(`L95@K^15UBQ0X5f|8}3(F3KE#883oK1wo-a_nbwS9 zLb)}=>{@K6e!|S%U81k=JjG&SD>}{uxQi*CD)VwkO`_nDbz|OqL=T2gJ&iyA6Sc4kE%< z5KAP2vRFc@Xj0@7{OVDrR&zCt-WVA=FEoesuRYLUpC8)KF^48!2C9ZWj zG7I4LMJH@NnTeg-R^rIoFR?P%4F}QDFzZS)t6UhYDPWTl0 zr5KhK0Zk2K@c2R^D8x#T;`f1GuB>$^zMfnS_1EAA@pW>>aJjpBz{Fs|`>?Y=44MOX z`!d;6BOx%>dgmIop=v7;)H+2diO7RVS7*qt6@sJn9`KAXlHe)uLN4YP<78twlK!eL_8sn$mC zNG{+Yz<|@2p{-@ks_KU*;=&5tyZJMsx9o*lri7Z0nG8?YZ^NaNUlG%% z4ZFtOS;kvBwnpH(?vRxiv*Vylh<|try~mOqsk0tx$t%^~96)E3Eyf&uim7)C@YR!e z=r*!takF>1z`$g(hgwT%G?`A@hmhnCGJ?kJO?ym#L>%5Z5+sJu2}=3z@%r%DXo{eUU+H00jKYg43R%uyyP~XNfeYZ>)lur8S&P6!5Sk&(a{;L$Gr- zm1go>7uSJs+BXPg_fw!@FGgu}Don>WXZuc@egjYLZf6-M(lLbCygmlaxq_nHH+T_o z3`sGUiQhZN3Y6cr!=!*EsenXiE|OJ@hI__f^sXn^J-`+_+{|&FR3_FTjmw$k} z=QkL4GYLNTH;^z?gr=va!`iw%JNf0(Yy|K40%w042!WS6JG&fxz752k z6$?qSV?%($Vu(mgZoRZKl)0o%K@`%)xnm7&;vh0)Gm1$xQdVAu_;dv&2a+-Pjz;^it?}*MmGJAH^hewsq@9n5STynA$W&gO=zHpg-H0+jl}x z!#^vpehkXWeR2PqL0rtX?9A!6xIBH)2YXWGNZ>?Dqn-L}LWQ1kk@e<2OzoN@H7yJm zTMUKo-J`Iv>&%*us);Qv%)sgKJs^Kv1Z{V7QoGKBlE5GWq77=2&Coh+P~9jeLm|-_DFhK2g?39zl*eR(*FXzH_J@(6LHusJ3C$pl z%*I#u1x zA-+SGXhm)rOeT7=eV0GZg6V|jC_kHtunTkFX(v(UlA;s|!_#;;KC>-r!uo!5hBVc{ z%}cZJW^~Xy(NrxWCP~N3QV9+IX~KlNAG6rtqoGC?dfwVAtzd18DKW*hL)y1URZPO$ z27I_J919JxAs!813oefbih_C(^X1WcTJ^nZk{liE=?3*HC4@G5D38s8-L{@6y_Ev3 z<`%GX>W=!JL#iX1PbJF0n23W&O}K&b$V}+8v0^ba4I2X}Gt0nt7hz;z6ePvj2oGEU zeIpx^`IjL7O$_qaJ*RR)!ftK&EL(@D8(WYy^F9fw_3^>?Co@V{4jQj%fXA_%#}879vShM8TnT1?2#FGSRnXYBW!+*~}1jzM|31X3>Q+n}<&z z8q(&xjt>)f2uGHMrE=dFGjp^B=(J}G?45k!)T}p(vJ;`--X5;sjYh-oz97!834t|6 z(ltl8dVDe-uUiM9p|}DJi_Rs{4;RLx+=xaHM@q8hMlHXt74k|XB+Dk5`OWZKsPE#8 z;^Gp-JbzB|6HP=ue8e(>ni5@FU#KLJmJu?I9-#O_0?cN5u}n~qJV2|NDfs%D$a(x2 zW^KKokd#9qErp{;Z^#LTX4lCN&3a5GA#M^J+gXtM7wT>;0gmZ;pD6+oSAU7Mn9@*yw_*Pr$oL9!Ogj2F*cc=stX3b@E50 zJeO#ICYf*>TWbvH*M~S!F6`}W(ZQ!3$<|HTb6(9oVPRobJtV3vbSyna9fYX2G17h_ zK;Jlmu<8)E>qt=B>=Kj*XOh57iqsc3;Lx-e?wnczp-7i#K@;}5Vq*v7R%$TqOD$Kt zZ*E9AfUD*<5N7yIJp9Y2uEF}`f_G&s+&N%-H>PZMI{4xmQ52bb`HNzxl%yiYuQ*?n z^$L*$sqOmZPJ|wrhR4@7VZh9bIJKcCEE;-|2*v`Xp(!vWFtJXHdMG32$!-N7A$;T9zpy@D@#1TeF(mUS(IebV#vuK1@&}^&xi`$ zfg#_AQiq1jw1+4;p8QTFk1j?q+0ueL;Olesn2DdhaPKdzJfODk`GWw9*T4FwWy~ZY zt?ut2mpq~n4{vXPm(M&_n(f^ENX);Uy+)z(W?hJO#D-$Pj0-ri$cLB)A#sRGRyWI| zvru#;5t?m`&|&DhYTsZ-q@DnMECafA=~VqJn!I^5Yg(Pe6&8^y8_@vD0N);6+4J1W zKIm9%NPZBxa1Epn^T4;#W=#pBuBdm&QVXh0po6C;cKW4jCDlkjdnD{U!ld!-tP?H{w3qmyVt}jK?%(0}8$Nws<`}0QP%(qclDnM)i%69DSMih9a2Q zco321Ad`gJZTc^(z@7*rkN@nA7IWvvKs@39El&>bVTdLVpG3|`+W zfq+P8#>(3;?&*N8drrW>#F>2t^$V1pn#ANtBoz@6m3%8pOX8SiMEbQ59TN-p#%?Gg z^#wYCB2sgpd5lV>LP<#}#A1Ecw6XoAN8{?)k;uQ41dBPXNvN%WeRFNJS>+V`o*1us+a%PT>jmaeoX&K z7fg+oxpQerwW3E3J6T>mW+P6vE-NenXTL=!h zf~&-Y<>lwI&%1T|F0O}$;dMd+E{0rYt7?=q4C_NjpFy|pjj>Vc4T&y6S4j|bW@ZwS zpT&bO&}7>e=O(Z#(@P67AQ0)XwUf(%;}AdMBneQ2EMtvEyQ?`;{*UHP&GJ6izKKsF znqLHM8F2GZ`=-i0Hg?PoU32E}3{OU`D4~mVnF9;lm@2w-I=`EQEW)d$VrXX zObNf>TAer()T?TW3}igaRR?GARjFwa|q4i${;5$gzw>Pu>+Z3h z=(oxZ16vkD*MN&H=XJ65+d}B(W}yGVvk2b#1zV2~-ZdKShOa{4S}3zoiTz+M&g42(`U~D(lTa*b7^Ezwd=-gMN9OXaSj)@kAUBVBPb-f z#e>sx&}*YBc7B@+<*E?4EcC**J5~sZ)Iq|jpYN~M)$XBBf;j$&mMvSxY~1NDNq!u{<(i1RWiKoCB2b*E z{CWl+krHL!)n_3uD})}Y6K~w+kKP?MV0bMOFQSZ760HU zsTAg+^~*a@>g%BB!fWX9%2B_o8Dd}NVe+y-cJ-k-Nj8rk3XC?!~Ut?Y(qhUB~WO%5ml8bA&uKDj^VBK;p#maI~c-QW7dcKzmauTp!+r zu8%duJ)9tk&O!0rEHr(37fPXJt$tk_Hh^_KOEx)^%jK+A&}fQ&x3RH?M^kqKd)TmA zLc>|1h7e+cU)8H;g@S^@T79WCE#PwJHcB7o(S6kr`#BQ8(H1RUJ%E*A#k|Hw9l;6b zR;+`r4!0_|dI|d*@_SDV=)Dr=&&lqY!%qKM4sm@d(sTkHz|W&=QHjC&!K4T&w$`;U`DLMQKCR0^US%Qeb~qgS_JM}z_sSVHH4h&@K}Kd4 zs|l?6DJo|1C7P#XkSsbSH4So-X^Tnz5FUO9jT<+rHLm=;ScFZT0ftz0x`{GvNf=~r ziXkx9f!+W|c=el&g3>s|OxepWsH-NDi5rh?qUP)9{@~SXQt0OhRX@uCAS zAACiprDZo_c6e3N#mE@k+&&YAo(|4m0B_L86*-$9;px3yXxsg# z3bd0rY9+D%P2C%@Qcn@s!oorpan+=N9?=F0jnkyAidwevs(4KpoTp^vDc)->;J3vS=Xe1ZQYaw z3!ZVg+CokhB*jQoP!p#sw9frg%oF+njv%5J*e^}BvWk!S#lBU zz~$G{F(z*l0`@E=MV@-l*4839LznkHlbR}(N=+beA?whqDs=41ZSq}K7>BW>?%nTh zI<%UYfbR1v2~S3W{|oT+NwH^fTjU*$X7@VOG}d%`g4M?MN;}6str_Mbi>guv^!#GY zT5@>~{@Q+{_fnm1@1>tJ79H)qXl-DpN{LY$I<+j-;#o~LpW9MJ(%M>W=p+;dy@H(B zdJ8fcd`le`R#2BJA#Nx}PC4t;K+vRlTR7 zYY_Ru2)fStWO9}4^a<+e!eU7qDAf|=9U)B6tGzmf_GHkvzKW#fyBc-);!#z;K(!#J zpsei6?{;`Ib+DVXit2mRS+_PHgs>d=%tNgYB{ zQv=4vM#Lm(vsJm`l9F2Y&?qVFA*lqag$jsl^dJk4g~Qf9ki_Lw z{tChGYoa(Jj+)y-v#j@&z^OI;!uKmrbeZy1w-ad@`BN6GINsjh|Gs%$L{W9(BbKf_ zZTBWKtL1~cu^mk7nTUuTFIzOP>*5}5+T_=~@5K5}Dnu%nqD!xN8ZL&=8)!@Fko+K%(RA(61f$} z&OXGmTrt|)6kx|S19WICMG+%JUbY(Hk91TQe$0K8CzGor(%e?ch*DB5Os4Ce)ST8r z?$GHsYC#zEyNlQKSME(6cYAs{g`PTdZ*pOBQj1s72VDQIzo-+{TJn9rHHKE-YDeES%~8&aK)8=e zgQuH5%=8toF;?Pbx(0^%lp-@rgnDAR>d2Jvl9t16x2S6K{MO-C1p{7AXQb*_??FHR zShabx>Uu~>l&!H?``pQ0(^gkbe8jeW!FF}&wEs5wj#@MS>C+^G%I&Q@?|#ddT)cNr z?_=6lZHYa#y=TfcEhE*H@vRt<%RJ`l*1=4`#7@km}-)^)o}c)_MMylMiMQJjEtg^EB^C# zd`u!#WeURlCvKj#OJi~$j}mTkS`w$LLsE+&C7_&X69Wv|=R%+k22HFaSUb%Y6IQ=L zQlU1=2yz;{G#gQqu8@eQ0lRWg!%oclj@u6fxcNep`RPmLI-en1*>#p5Yf`AzIC)Ij zI(x>*C6h+C&2Hc|?bPeoH~kf*=j&|#>NJmVax%};GwM~)Z>Ue_Ela;(ci1mo6Xh?&V>poQWsuV+8^roz{#xHawqR^gFhuBrg;L*A2u8cu~_!~ zu6RQmo3*d+tY3x-6a0S{eWfqhs+Dtbm6Vv-j(j2KAXT0hsBtvto;!7TAKMT8bZRp_ z#Bb;!SCg$Rq`3zkV{djN%(+Pl{RT=*>z)r!dnqnN=|HaJu)6@J`sShQYI}^|WP}{0 z7P59dQk(wjRn%kP-Zqp8HT(V;IurcR*0cSbUA7hm^1`%0ZHBI8upCryBQdego%Ls< zsS6Kw^5{OF{zwRqN>;-*vo^YRn6vR?nNdYm5!JH?hdSIoI4ErVnBnTqy(Tl_#&ei? z)5kN%HhjStJAcny-93WQtUr&j_L{@2pX;Z-esH4y%gCeNpAs3pO