diff --git a/src/main/java/kr/allcll/backend/admin/preseat/AdminPreSeatService.java b/src/main/java/kr/allcll/backend/admin/preseat/AdminPreSeatService.java index 9aeaa9ce..7f03d62b 100644 --- a/src/main/java/kr/allcll/backend/admin/preseat/AdminPreSeatService.java +++ b/src/main/java/kr/allcll/backend/admin/preseat/AdminPreSeatService.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import java.util.List; import kr.allcll.backend.admin.preseat.dto.PreSeatResponse; +import kr.allcll.backend.admin.seat.SeatStreamStatus; +import kr.allcll.backend.admin.seat.SeatStreamStatusService; import kr.allcll.crawler.client.SeatClient; import kr.allcll.crawler.client.model.SeatResponse; import kr.allcll.crawler.client.payload.SeatPayload; @@ -25,10 +27,11 @@ public class AdminPreSeatService { private final SeatClient seatClient; private final Credentials credentials; private final AllPreSeatBuffer allPreSeatBuffer; + private final SeatStreamStatusService seatStreamStatusService; private final CrawlerSubjectRepository crawlerSubjectRepository; - public void getAllPreSeat(String userId) { + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.PRESEAT); Credential credential = credentials.findByUserId(userId); List crawlerSubjects = crawlerSubjectRepository.findAllBySemesterAt(CrawlerSemester.now()); for (CrawlerSubject crawlerSubject : crawlerSubjects) { @@ -52,6 +55,7 @@ private void sendExternalPreSeatsRequest(CrawlerSubject crawlerSubject, Credenti } catch (CrawlerAllcllException e) { log.error( "[여석] 외부 API 호출에 실패했습니다. PreSeat: " + crawlerSubject.getCuriNo() + "-" + crawlerSubject.getClassName()); + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.ERROR); } } diff --git a/src/main/java/kr/allcll/backend/admin/seat/AdminSeatApi.java b/src/main/java/kr/allcll/backend/admin/seat/AdminSeatApi.java index 504d3182..59e149e3 100644 --- a/src/main/java/kr/allcll/backend/admin/seat/AdminSeatApi.java +++ b/src/main/java/kr/allcll/backend/admin/seat/AdminSeatApi.java @@ -2,7 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import kr.allcll.backend.admin.AdminRequestValidator; -import kr.allcll.backend.admin.seat.dto.SeatStatusResponse; +import kr.allcll.backend.admin.seat.dto.SeatCrawlingStatusResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -41,12 +41,12 @@ public ResponseEntity seasonSeatStart(HttpServletRequest request, } @GetMapping("/api/admin/seat/check") - public ResponseEntity getSeatStatus(HttpServletRequest request) { + public ResponseEntity getSeatStatus(HttpServletRequest request) { if (validator.isRateLimited(request) || validator.isUnauthorized(request)) { return ResponseEntity.status(401).build(); } - SeatStatusResponse seatStatusResponse = adminSeatService.getSeatCrawlerStatus(); - return ResponseEntity.ok(seatStatusResponse); + SeatCrawlingStatusResponse seatCrawlingStatusResponse = adminSeatService.getSeatCrawlingStatus(); + return ResponseEntity.ok(seatCrawlingStatusResponse); } @PostMapping("/api/admin/seat/cancel") diff --git a/src/main/java/kr/allcll/backend/admin/seat/AdminSeatService.java b/src/main/java/kr/allcll/backend/admin/seat/AdminSeatService.java index 91ef518b..bb7cbb10 100644 --- a/src/main/java/kr/allcll/backend/admin/seat/AdminSeatService.java +++ b/src/main/java/kr/allcll/backend/admin/seat/AdminSeatService.java @@ -5,7 +5,7 @@ import java.time.LocalDateTime; import java.util.concurrent.atomic.AtomicLong; import kr.allcll.backend.admin.seat.dto.ChangeSubjectsResponse; -import kr.allcll.backend.admin.seat.dto.SeatStatusResponse; +import kr.allcll.backend.admin.seat.dto.SeatCrawlingStatusResponse; import kr.allcll.crawler.client.SeatClient; import kr.allcll.crawler.client.model.SeatResponse; import kr.allcll.crawler.client.payload.SeatPayload; @@ -25,24 +25,28 @@ @RequiredArgsConstructor public class AdminSeatService { + private final AtomicLong lastSuccessCrawlingTime = new AtomicLong(0); private static final long RECENT_CRAWLING_SUCCESS_THRESHOLD_MS = 3_000; + private final SeatClient seatClient; private final Credentials credentials; + private final AllSeatBuffer allSeatBuffer; + private final ChangeDetector changeDetector; + private final SjptProperties sjptProperties; private final TargetSubjectStorage targetSubjectStorage; private final CrawlerScheduledTaskHandler seatScheduler; private final SeatPersistenceService seatPersistenceService; - private final SjptProperties sjptProperties; - private final ChangeDetector changeDetector; - private final AllSeatBuffer allSeatBuffer; - private final AtomicLong lastSuccessCrawlingTime = new AtomicLong(0); + private final SeatStreamStatusService seatStreamStatusService; public void getAllSeatPeriodically(String userId) { + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.LIVE); Credential credential = credentials.findByUserId(userId); fetchPinSeat(credential); fetchGeneralSeat(credential); } public void getSeasonSeatPeriodically(String userId) { + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.LIVE); Credential credential = credentials.findByUserId(userId); fetchPinSeat(credential); fetchGeneralSeat(credential); @@ -50,9 +54,10 @@ public void getSeasonSeatPeriodically(String userId) { public void cancelSeatScheduling() { seatScheduler.cancelAll(); + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.IDLE); } - public SeatStatusResponse getSeatCrawlerStatus() { + public SeatCrawlingStatusResponse getSeatCrawlingStatus() { int seatSchedulerTaskCount = seatScheduler.getTaskCount(); boolean validSeatSchedulerCount = seatSchedulerTaskCount == sjptProperties.getRequestPerSecondCount(); boolean validRecentCrawlingSuccess = @@ -60,7 +65,7 @@ public SeatStatusResponse getSeatCrawlerStatus() { boolean isActive = validSeatSchedulerCount && validRecentCrawlingSuccess; - return SeatStatusResponse.of(isActive); + return SeatCrawlingStatusResponse.of(isActive); } private void fetchPinSeat(Credential credential) { @@ -125,6 +130,7 @@ private void sendExternalRequestWithOutDetect(CrawlerSubject crawlerSubject, Cre } catch (CrawlerAllcllException e) { log.error( "[여석] 외부 API 호출에 실패했습니다. 과목: " + crawlerSubject.getCuriNo() + "-" + crawlerSubject.getClassName()); + seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.ERROR); } } diff --git a/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatus.java b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatus.java new file mode 100644 index 00000000..50dbae6b --- /dev/null +++ b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatus.java @@ -0,0 +1,18 @@ +package kr.allcll.backend.admin.seat; + +public enum SeatStreamStatus { + LIVE("과목의 여석을 실시간으로 볼 수 있어요."), + PRESEAT("전체 학년 여석 탭을 이용해 주세요."), + IDLE("과목 실시간 여석 제공 전이에요. 서비스가 시작되면 알림을 드릴게요."), + ERROR("서비스 점검 중이에요. 조금만 기다려주세요."); + + private final String message; + + SeatStreamStatus(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusService.java b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusService.java new file mode 100644 index 00000000..448b1110 --- /dev/null +++ b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusService.java @@ -0,0 +1,34 @@ +package kr.allcll.backend.admin.seat; + +import java.util.Objects; +import kr.allcll.backend.admin.seat.dto.SeatStreamStatusResponse; +import kr.allcll.backend.support.sse.SseEventBuilderFactory; +import kr.allcll.backend.support.sse.SseService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SeatStreamStatusService { + + private final SseService sseService; + public final SeatStreamStatusStore seatStreamStatusStore; + + public void updateSeatStreamStatus(SeatStreamStatus newStatus) { + SeatStreamStatus currentStatus = seatStreamStatusStore.getCurrentStatus(); + if (Objects.equals(currentStatus, newStatus)) { + return; + } + seatStreamStatusStore.updateCurrentStatus(newStatus); + + SeatStreamStatusResponse sseStatusResponse = getSeatStreamStatusResponse(newStatus); + sseService.propagate(SseEventBuilderFactory.EVENT_SEAT_STREAM_STATUS, sseStatusResponse); + } + + private SeatStreamStatusResponse getSeatStreamStatusResponse(SeatStreamStatus newStatus) { + return SeatStreamStatusResponse.of( + newStatus.name().toLowerCase(), + newStatus.getMessage() + ); + } +} diff --git a/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusStore.java b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusStore.java new file mode 100644 index 00000000..ff2e9af5 --- /dev/null +++ b/src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatusStore.java @@ -0,0 +1,21 @@ +package kr.allcll.backend.admin.seat; + +import org.springframework.stereotype.Component; + +@Component +public class SeatStreamStatusStore { + + private volatile SeatStreamStatus currentStatus; + + public SeatStreamStatusStore() { + this.currentStatus = SeatStreamStatus.IDLE; + } + + public SeatStreamStatus getCurrentStatus() { + return currentStatus; + } + + public void updateCurrentStatus(SeatStreamStatus newStatus) { + this.currentStatus = newStatus; + } +} diff --git a/src/main/java/kr/allcll/backend/admin/seat/dto/SeatCrawlingStatusResponse.java b/src/main/java/kr/allcll/backend/admin/seat/dto/SeatCrawlingStatusResponse.java new file mode 100644 index 00000000..ffe71183 --- /dev/null +++ b/src/main/java/kr/allcll/backend/admin/seat/dto/SeatCrawlingStatusResponse.java @@ -0,0 +1,10 @@ +package kr.allcll.backend.admin.seat.dto; + +public record SeatCrawlingStatusResponse( + boolean isActive +) { + + public static SeatCrawlingStatusResponse of(boolean isActive) { + return new SeatCrawlingStatusResponse(isActive); + } +} diff --git a/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStatusResponse.java b/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStatusResponse.java deleted file mode 100644 index d172a7e9..00000000 --- a/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStatusResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package kr.allcll.backend.admin.seat.dto; - -public record SeatStatusResponse( - boolean isActive -) { - - public static SeatStatusResponse of(boolean isActive) { - return new SeatStatusResponse(isActive); - } -} diff --git a/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStreamStatusResponse.java b/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStreamStatusResponse.java new file mode 100644 index 00000000..55839431 --- /dev/null +++ b/src/main/java/kr/allcll/backend/admin/seat/dto/SeatStreamStatusResponse.java @@ -0,0 +1,11 @@ +package kr.allcll.backend.admin.seat.dto; + +public record SeatStreamStatusResponse( + String status, + String message +) { + + public static SeatStreamStatusResponse of(String status, String message) { + return new SeatStreamStatusResponse(status, message); + } +} diff --git a/src/main/java/kr/allcll/backend/support/sse/SseEventBuilderFactory.java b/src/main/java/kr/allcll/backend/support/sse/SseEventBuilderFactory.java index 11f87985..f676a95c 100644 --- a/src/main/java/kr/allcll/backend/support/sse/SseEventBuilderFactory.java +++ b/src/main/java/kr/allcll/backend/support/sse/SseEventBuilderFactory.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import kr.allcll.backend.admin.seat.SeatStreamStatus; +import kr.allcll.backend.admin.seat.dto.SeatStreamStatusResponse; import org.springframework.http.MediaType; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter.SseEventBuilder; @@ -12,6 +14,9 @@ public class SseEventBuilderFactory { private static final ObjectMapper objectMapper = new ObjectMapper(); + public static final String EVENT_CONNECTION = "connection"; + public static final String EVENT_SEAT_STREAM_STATUS = "status"; + private static final long RECONNECT_TIME_MILLIS = 1000L; static { @@ -32,10 +37,26 @@ public static SseEventBuilder create(String eventName, Object value) { public static SseEventBuilder createInitialEvent() { return baseEvent() - .name("connection") + .name(EVENT_CONNECTION) .data("success", MediaType.TEXT_PLAIN); } + public static SseEventBuilder createInitialSeatStreamStatusEvent(SeatStreamStatus seatStreamStatus) { + SeatStreamStatusResponse seatStreamStatusResponse = SeatStreamStatusResponse.of( + seatStreamStatus.name().toLowerCase(), + seatStreamStatus.getMessage() + ); + + try { + String sseEvent = objectMapper.writeValueAsString(seatStreamStatusResponse); + return baseEvent() + .name(EVENT_SEAT_STREAM_STATUS) + .data(sseEvent, MediaType.APPLICATION_JSON); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + private static SseEventBuilder baseEvent() { return SseEmitter.event() .reconnectTime(RECONNECT_TIME_MILLIS); diff --git a/src/main/java/kr/allcll/backend/support/sse/SseService.java b/src/main/java/kr/allcll/backend/support/sse/SseService.java index 14999795..24949773 100644 --- a/src/main/java/kr/allcll/backend/support/sse/SseService.java +++ b/src/main/java/kr/allcll/backend/support/sse/SseService.java @@ -2,7 +2,8 @@ import java.io.IOException; import java.util.List; -import kr.allcll.backend.support.sse.dto.SseStatusResponse; +import kr.allcll.backend.admin.seat.SeatStreamStatus; +import kr.allcll.backend.admin.seat.SeatStreamStatusStore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,12 +16,20 @@ public class SseService { private final SseEmitterStorage sseEmitterStorage; + private final SeatStreamStatusStore seatStreamStatusStore; public SseEmitter connect(String token) { SseEmitter sseEmitter = createSseEmitter(); sseEmitterStorage.add(token, sseEmitter); + SseEventBuilder initialEvent = SseEventBuilderFactory.createInitialEvent(); sendEvent(sseEmitter, initialEvent); + + SeatStreamStatus seatStreamStatus = seatStreamStatusStore.getCurrentStatus(); + SseEventBuilder initialSeatStreamStatusEvent = SseEventBuilderFactory.createInitialSeatStreamStatusEvent( + seatStreamStatus); + sendEvent(sseEmitter, initialSeatStreamStatusEvent); + return sseEmitter; } @@ -54,9 +63,4 @@ private void sendEvent(SseEmitter sseEmitter, SseEventBuilder eventBuilder) { public List getConnectedTokens() { return sseEmitterStorage.getUserTokens().stream().toList(); } - - public SseStatusResponse isConnected(String token) { - boolean isConnected = sseEmitterStorage.getEmitter(token).isPresent(); - return SseStatusResponse.of(isConnected); - } } diff --git a/src/main/java/kr/allcll/backend/support/sse/dto/SseStatusResponse.java b/src/main/java/kr/allcll/backend/support/sse/dto/SseStatusResponse.java deleted file mode 100644 index c31c3ac7..00000000 --- a/src/main/java/kr/allcll/backend/support/sse/dto/SseStatusResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package kr.allcll.backend.support.sse.dto; - -public record SseStatusResponse( - boolean isConnected -) { - public static SseStatusResponse of(boolean isConnected) { - return new SseStatusResponse(isConnected); - } -} diff --git a/src/test/java/kr/allcll/backend/support/sse/SseServiceTest.java b/src/test/java/kr/allcll/backend/support/sse/SseServiceTest.java index ceaba2ab..b8c56c4c 100644 --- a/src/test/java/kr/allcll/backend/support/sse/SseServiceTest.java +++ b/src/test/java/kr/allcll/backend/support/sse/SseServiceTest.java @@ -53,6 +53,7 @@ void sseConnectionTest() { // then SseTestHelper.assertResponseContainsMessage(response, "success"); + SseTestHelper.assertResponseContainsMessage(response, "idle"); } @DisplayName("동시에 여러 클라이언트와 SSE 연결을 유지하고, 모두에게 메시지를 전달한다.") @@ -86,6 +87,10 @@ void ssePropagationTest() { + "data:success\n" + "\n" + "retry:1000\n" + + "event:status\n" + + "data:{\"status\":\"idle\",\"message\":\"과목 실시간 여석 제공 전이에요. 서비스가 시작되면 알림을 드릴게요.\"}\n" + + "\n" + + "retry:1000\n" + "event:message\n" + "data:\"Hello, SSE!\"\n" + "\n";