Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,4 +1,4 @@
package server.gooroomi.domain.bus.entity;
package server.gooroomi.domain.bus;

public enum MatchType {
EXACT, // 정확히 일치
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import server.gooroomi.domain.bus.application.SseEmitterService;
import server.gooroomi.domain.bus.application.BusAlertService;

@RestController
@RequiredArgsConstructor
Expand All @@ -23,22 +22,22 @@
@Slf4j
public class BusAlertSseController {

private final SseEmitterService sseEmitterService;
private final BusAlertService busAlertService;

@Operation(summary = "SSE 연결", description = "사용자 ID를 기반으로 SSE 연결을 생성하여 버스 도착 정보를 실시간으로 수신합니다.")
@Parameters({
@Parameter(name = "userId", description = "사용자 ID", required = true)
})
@Parameters({ @Parameter(name = "userId", description = "사용자 ID", required = true) })
@GetMapping(value = "/stream")
public SseEmitter stream(@RequestParam Long userId) {
log.info("[SSE 연결 요청] userId={}", userId);

try {
SseEmitter emitter = sseEmitterService.createEmitter(userId);
log.info("[SSE 연결 성공] userId={}", userId);
// 기존 연결이 있으면 자동으로 정리되고 새 연결이 생성됨
SseEmitter emitter = busAlertService.createEmitter(userId);
log.info("[SSE 연결 성공] userId={}, 현재 활성 연결 수={}", userId, busAlertService.getActiveConnectionCount());
return emitter;
} catch (Exception e) {
log.error("[SSE 연결 실패] userId={}, error={}", userId, e.getMessage(), e);
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "SSE 연결 실패", e);
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "SSE 연결 실패: " + e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,27 @@ public class BusRestController {
private final BusService busService;
private final BusOcrMatchingService busOcrMatchingService;

@Operation(summary = "곧 도착 버스 목록 조회", description = "사용자의 위치 기준으로 도착 예정인 버스 목록을 조회합니다. "
+ "사용자가 등록한 버스가 포함된 경우, 다음과 같이 응답합니다: "
+ "1. 사용자가 등록한 버스 1대만 도착하는 경우: code: 20002 "
+ "2. 사용자가 등록한 버스가 여러 대의 버스와 함께 도착하는 경우: code: 20003")
@Parameters({ @Parameter(name = "userId", description = "사용자 ID", required = true) })
@Operation(summary = "곧 도착 버스 목록 조회", description = """
사용자의 위치 기준으로 도착 예정인 버스 목록을 조회합니다.\n
응답 코드:
- 근처에 버스 정류소가 없는 경우: code: 20001
- 사용자가 등록한 버스 1대만 도착하는 경우: code: 20002
- 사용자가 등록한 버스가 여러 대의 버스와 함께 도착하는 경우: code: 20003
""")
@Parameters({@Parameter(name = "userId", description = "사용자 ID", required = true)})
@GetMapping("/arrivals")
public BaseResponse<List<BusArrivalResponse>> getBusArrivals(@RequestParam Long userId) {
return busService.getBusArrivals(userId);
}

@Operation(summary = "OCR 결과와 버스 번호 매칭", description = "OCR로 인식된 버스 번호와 도착 예정 버스 목록을 비교합니다.")
@Operation(summary = "OCR 결과와 버스 번호 매칭", description = """
OCR로 인식된 버스 번호와 도착 예정 버스 목록을 비교합니다.\n
응답 코드:
- 사용자 위치 정보가 등록되지 않은 경우: code : 40008
- 정확히 일치하는 버스 번호가 있는 경우: MatchType.EXACT
- 유사한 버스 번호가 있는 경우(유사도 0.8 이상): MatchType.SIMILAR
- 일치하는 버스가 없는 경우: MatchType.NONE
""")
@PostMapping("/ocr-process")
public BaseResponse<OcrProcessResponse> processOcrResult(@RequestBody OcrProcessRequest request) {
return busOcrMatchingService.processOcrResult(request);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package server.gooroomi.domain.bus.application;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import server.gooroomi.domain.bus.dto.BusArrivalDto;
import server.gooroomi.domain.user.application.UserService;
import server.gooroomi.domain.user.entity.User;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* 버스 알림 서비스 사용자에게 버스 도착 알림 전송
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BusAlertService {

private static final Long TIMEOUT = 60 * 1000L * 10; // 10분 유지
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
private final UserService userService;

/**
* 사용자를 위한 SSE 연결 생성
*/
public SseEmitter createEmitter(Long userId) {
// 기존 Emitter가 있으면 제거
removeEmitterIfExists(userId);

// 새 Emitter 생성
SseEmitter emitter = new SseEmitter(TIMEOUT);
emitters.put(userId, emitter);
log.info("[SSE Emitter 생성] userId={}", userId);

// 완료 시 Emitter 제거
emitter.onCompletion(() -> {
emitters.remove(userId);
log.info("[SSE 연결 종료] userId={}", userId);
});

// 타임아웃 시 Emitter 제거
emitter.onTimeout(() -> {
emitters.remove(userId);
log.warn("[SSE 타임아웃] userId={}", userId);
});

// 오류 발생 시 Emitter 제거
emitter.onError((e) -> {
emitters.remove(userId);
log.error("[SSE 오류] userId={}, error={}", userId, e.getMessage(), e);
});

// 초기 연결 이벤트 전송
try {
emitter.send(SseEmitter.event().name("connect").data("SSE 연결 완료"));
log.info("[초기 이벤트 전송 완료] userId={}", userId);
} catch (IOException e) {
emitters.remove(userId);
log.error("[초기 이벤트 전송 실패] userId={}, error={}", userId, e.getMessage(), e);
}

return emitter;
}

/**
* 기존 Emitter가 있으면 제거
*/
private void removeEmitterIfExists(Long userId) {
SseEmitter existingEmitter = emitters.get(userId);
if (existingEmitter != null) {
try {
existingEmitter.complete();
} catch (Exception e) {
log.warn("[기존 Emitter 제거 중 오류] userId={}, error={}", userId, e.getMessage());
} finally {
emitters.remove(userId);
log.info("[기존 Emitter 제거] userId={}", userId);
}
}
}

/**
* 사용자에게 버스 도착 알림 전송 도착 예정 버스 목록 중 사용자가 등록한 버스가 있는지 확인하고 알림 전송
*/
public void notifyUserIfBusArriving(Long userId, List<BusArrivalDto> busArrivals) {
// Emitter가 없으면 조용히 반환
SseEmitter emitter = emitters.get(userId);
if (emitter == null) {
log.warn("[알림 전송 스킵] userId={} - Emitter 없음", userId);
return;
}

// 사용자 정보 조회
User user = userService.getUserById(userId);
String userBusNumber = user.getBusNumber();

// 사용자가 버스 번호를 등록하지 않은 경우
if (userBusNumber == null || userBusNumber.isEmpty()) {
log.warn("[알림 전송 스킵] userId={} - 등록된 버스 번호 없음", userId);
return;
}

// 사용자가 등록한 버스가 도착 예정인지 확인
boolean found = busArrivals.stream().anyMatch(arrival -> arrival.getBusNumber().equals(userBusNumber));

// 사용자가 등록한 버스가 도착 예정이면 알림 전송
if (found) {
try {
// 알림 전송
emitter.send(SseEmitter.event()
.name("bus-arrival")
.data(userBusNumber + "번 버스가 곧 도착합니다."));
log.info("[알림 전송 성공] userId={}, bus={}", userId, userBusNumber);
} catch (IOException e) {
emitters.remove(userId);
log.error("[알림 전송 실패] userId={}, error={}", userId, e.getMessage(), e);
}
}
}

/**
* 모든 활성 SSE 연결 수 반환
*/
public int getActiveConnectionCount() {
return emitters.size();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package server.gooroomi.domain.bus.application;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
import org.springframework.stereotype.Service;
import server.gooroomi.domain.bus.api.BusInfoApiClient;
import server.gooroomi.domain.bus.dto.BusArrivalDto;

import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

/**
* 버스 도착 정보 조회 서비스 정류소 ID(arsId)를 기반으로 도착 예정 버스 정보 조회
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BusArrivalService {

private final BusInfoApiClient busInfoApiClient;

/**
* 정류소 ID(arsId)로 도착 예정 버스 목록 조회
*/
public List<BusArrivalDto> getBusArrivals(String arsId) {
String json = busInfoApiClient.getBusArrivals(arsId);
JSONObject root = new JSONObject(json);
JSONArray itemList = root.getJSONObject("msgBody").optJSONArray("itemList");

if (itemList == null) {
log.info("정류장 ID {}에 도착 예정인 버스가 없습니다.", arsId);
return new ArrayList<>();
}

// 도착 예정 버스 목록 중 조건에 맞는 버스만 필터링하여 DTO로 변환
List<BusArrivalDto> busArrivals = IntStream.range(0, itemList.length()).mapToObj(i -> {
JSONObject item = itemList.getJSONObject(i);
String busNumber = item.getString("rtNm");
String arrivalTime = item.getString("traTime1");
String arrmsg1 = item.getString("arrmsg1");
int arrivalInSeconds = Integer.parseInt(arrivalTime);

// 운행종료, 출발대기, 90초 이상 남은 버스는 제외
if ("운행종료".equals(arrmsg1) || "출발대기".equals(arrmsg1) || arrivalInSeconds > 90) {
return null;
}

return new BusArrivalDto(busNumber, arrivalTime);
}).filter(Objects::nonNull).toList();

// 도착 예정 버스 목록 로깅
if (busArrivals.isEmpty()) {
log.info("정류장 ID {}에 90초 이내 도착 예정인 버스가 없습니다.", arsId);
} else {
log.info("[정류장 ID {}에 도착 예정인 버스 목록] {}", arsId,
busArrivals.stream().map(bus -> bus.getBusNumber() + "(" + bus.getArrivalTime() + "초)")
.collect(Collectors.joining(", ")));
}

return busArrivals;
}
}
Loading
Loading