Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<CrawlerSubject> crawlerSubjects = crawlerSubjectRepository.findAllBySemesterAt(CrawlerSemester.now());
for (CrawlerSubject crawlerSubject : crawlerSubjects) {
Expand All @@ -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);
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/main/java/kr/allcll/backend/admin/seat/AdminSeatApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,12 +41,12 @@ public ResponseEntity<Void> seasonSeatStart(HttpServletRequest request,
}

@GetMapping("/api/admin/seat/check")
public ResponseEntity<SeatStatusResponse> getSeatStatus(HttpServletRequest request) {
public ResponseEntity<SeatCrawlingStatusResponse> 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")
Expand Down
20 changes: 13 additions & 7 deletions src/main/java/kr/allcll/backend/admin/seat/AdminSeatService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,42 +25,47 @@
@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);
}

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 =
(System.currentTimeMillis() - lastSuccessCrawlingTime.get()) <= RECENT_CRAWLING_SUCCESS_THRESHOLD_MS;

boolean isActive = validSeatSchedulerCount && validRecentCrawlingSuccess;

return SeatStatusResponse.of(isActive);
return SeatCrawlingStatusResponse.of(isActive);
}

private void fetchPinSeat(Credential credential) {
Expand Down Expand Up @@ -125,6 +130,7 @@ private void sendExternalRequestWithOutDetect(CrawlerSubject crawlerSubject, Cre
} catch (CrawlerAllcllException e) {
log.error(
"[여석] 외부 API 호출에 실패했습니다. 과목: " + crawlerSubject.getCuriNo() + "-" + crawlerSubject.getClassName());
seatStreamStatusService.updateSeatStreamStatus(SeatStreamStatus.ERROR);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover stream status after transient crawl exceptions

This sets the global stream status to ERROR on any single CrawlerAllcllException, but the commit only transitions back to LIVE when an admin explicitly starts crawling again. During an active scheduler run, later successful polls do not restore LIVE, so one transient API failure can leave all current and newly connected clients seeing a persistent error state that no longer matches actual service health.

Useful? React with 👍 / 👎.

}
}

Expand Down
18 changes: 18 additions & 0 deletions src/main/java/kr/allcll/backend/admin/seat/SeatStreamStatus.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions src/main/java/kr/allcll/backend/support/sse/SseService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize SSE connect with initial status emission

Because connect registers the emitter before finishing its initial status send, a concurrent updateSeatStreamStatus(...) can broadcast a newer status to this emitter and then connect can immediately send an older snapshot afterward. In that race, a client can observe a state regression (for example live followed by idle) and keep the stale status until another transition occurs, so the first status handshake is not reliable under concurrent admin actions.

Useful? React with 👍 / 👎.


SseEventBuilder initialEvent = SseEventBuilderFactory.createInitialEvent();
sendEvent(sseEmitter, initialEvent);

SeatStreamStatus seatStreamStatus = seatStreamStatusStore.getCurrentStatus();
SseEventBuilder initialSeatStreamStatusEvent = SseEventBuilderFactory.createInitialSeatStreamStatusEvent(
seatStreamStatus);
sendEvent(sseEmitter, initialSeatStreamStatusEvent);

return sseEmitter;
}

Expand Down Expand Up @@ -54,9 +63,4 @@ private void sendEvent(SseEmitter sseEmitter, SseEventBuilder eventBuilder) {
public List<String> getConnectedTokens() {
return sseEmitterStorage.getUserTokens().stream().toList();
}

public SseStatusResponse isConnected(String token) {
boolean isConnected = sseEmitterStorage.getEmitter(token).isPresent();
return SseStatusResponse.of(isConnected);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ void sseConnectionTest() {

// then
SseTestHelper.assertResponseContainsMessage(response, "success");
SseTestHelper.assertResponseContainsMessage(response, "idle");
}

@DisplayName("동시에 여러 클라이언트와 SSE 연결을 유지하고, 모두에게 메시지를 전달한다.")
Expand Down Expand Up @@ -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";
Expand Down
Loading