From b6c91d416ab10bf8e6b7956a7036a2741addf2a1 Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:22:18 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[rename/#41]=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EB=AA=85,=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bus/{entity => }/MatchType.java | 2 +- .../bus/application/BusAlertService.java | 132 ++++++++++++++++++ .../bus/application/SseEmitterService.java | 86 ------------ 3 files changed, 133 insertions(+), 87 deletions(-) rename src/main/java/server/gooroomi/domain/bus/{entity => }/MatchType.java (75%) create mode 100644 src/main/java/server/gooroomi/domain/bus/application/BusAlertService.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/application/SseEmitterService.java diff --git a/src/main/java/server/gooroomi/domain/bus/entity/MatchType.java b/src/main/java/server/gooroomi/domain/bus/MatchType.java similarity index 75% rename from src/main/java/server/gooroomi/domain/bus/entity/MatchType.java rename to src/main/java/server/gooroomi/domain/bus/MatchType.java index cad73db..6f9177b 100644 --- a/src/main/java/server/gooroomi/domain/bus/entity/MatchType.java +++ b/src/main/java/server/gooroomi/domain/bus/MatchType.java @@ -1,4 +1,4 @@ -package server.gooroomi.domain.bus.entity; +package server.gooroomi.domain.bus; public enum MatchType { EXACT, // 정확히 일치 diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusAlertService.java b/src/main/java/server/gooroomi/domain/bus/application/BusAlertService.java new file mode 100644 index 0000000..cb65838 --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/application/BusAlertService.java @@ -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 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 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(); + } +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/application/SseEmitterService.java b/src/main/java/server/gooroomi/domain/bus/application/SseEmitterService.java deleted file mode 100644 index a8131c1..0000000 --- a/src/main/java/server/gooroomi/domain/bus/application/SseEmitterService.java +++ /dev/null @@ -1,86 +0,0 @@ -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.entity.BusArrival; -import server.gooroomi.domain.user.entity.User; -import server.gooroomi.domain.user.repository.UserRepository; -import server.gooroomi.global.handler.response.BaseException; -import server.gooroomi.global.handler.response.BaseResponseStatus; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SseEmitterService { - - private static final Long TIMEOUT = 60 * 1000L; // 1시간 유지 - private final Map emitters = new ConcurrentHashMap<>(); - private final UserRepository userRepository; - - // 사용자가 SSE 연결을 맺고 이벤트 발생 시 알림을 보낼 수 있도록 설정 - public SseEmitter createEmitter(Long userId) { - SseEmitter emitter = new SseEmitter(TIMEOUT); // 타임아웃 설정 - emitters.put(userId, emitter); - log.info("[SSE Emitter 생성] userId={}", userId); - - emitter.onCompletion(() -> { - emitters.remove(userId); - log.info("[SSE 연결 종료] userId={}", userId); - }); - - emitter.onTimeout(() -> { - emitters.remove(userId); - log.warn("[SSE 타임아웃] userId={}", userId); - }); - - 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) { - log.error("[초기 이벤트 전송 실패] userId={}, error={}", userId, e.getMessage(), e); - } - return emitter; - } - - // 도착 예정 버스 목록 중 사용자가 원하는 버스가 있으면 SSE 이벤트 전송 - public void notifyUserIfBusArriving(Long userId, List busArrivals) { - SseEmitter emitter = emitters.get(userId); - if (emitter == null) { - log.warn("[알림 전송 실패] userId={} - Emitter 없음", userId); - return; - } - - User user = userRepository.findById(userId) - .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); - - boolean found = busArrivals.stream() - .anyMatch(arrival -> arrival.getBusNumber().equals(user.getBusNumber())); - - if (found) { - try { - emitter.send(SseEmitter.event() - .name("bus-arrival") // 이벤트 이름 - .data(user.getBusNumber() + "번 버스가 곧 도착합니다.")); // 이벤트 내용 - log.info("[알림 전송 성공] userId={}, bus={}", userId, user.getBusNumber()); - } catch (IOException e) { - emitters.remove(userId); // 예외 발생 시 emitter 제거 - log.error("[알림 전송 실패] userId={}, error={}", userId, e.getMessage(), e); - - } - } - } -} From a2d6c4153449db1bec6797c5bb3dc363c7eee660 Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:24:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[refactor/#41]=20LOCATION=5FNOT=5FUPDATED?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gooroomi/global/handler/response/BaseResponseStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/server/gooroomi/global/handler/response/BaseResponseStatus.java b/src/main/java/server/gooroomi/global/handler/response/BaseResponseStatus.java index fc2bca2..e0c3ae5 100644 --- a/src/main/java/server/gooroomi/global/handler/response/BaseResponseStatus.java +++ b/src/main/java/server/gooroomi/global/handler/response/BaseResponseStatus.java @@ -31,6 +31,7 @@ public enum BaseResponseStatus { REQ_BINDING_FAIL(false, 40005, HttpStatus.BAD_REQUEST, "잘못된 request 입니다."), MISMATCH_PARAM_TYPE(false, 40006, HttpStatus.BAD_REQUEST, "잘못된 파라미터 타입입니다."), FAILED_VALIDATION(false, 40007, HttpStatus.BAD_REQUEST, "입력값이 누락되었거나, 부적절한 입력 값이 있습니다."), + LOCATION_NOT_REGISTERED(false, 40008, HttpStatus.BAD_REQUEST, "사용자 위치 정보가 아직 등록되지 않았습니다."), /** * 401 UNAUTHORIZED 권한없음(인증 실패) @@ -48,7 +49,6 @@ public enum BaseResponseStatus { NOT_FOUND(false, 404, HttpStatus.NOT_FOUND, "Not Found"), NOT_FOUND_USER(false, 40401, HttpStatus.NOT_FOUND, "해당 User를 찾을 수 없습니다."), NOT_FOUND_STATION(false, 40402, HttpStatus.NOT_FOUND, "주변에 정류장이 없습니다."), - LOCATION_NOT_UPDATED(false, 40403, HttpStatus.NOT_FOUND, "사용자 위치 정보가 아직 업데이트되지 않았습니다."), /** * 405 METHOD_NOT_ALLOWED 지원하지 않은 method 호출 From 829312f72c23c7615b74b03d63833f87324251a7 Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:28:23 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[refactor/#41]=20=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EC=9D=BC=EA=B4=80=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/bus/api/BusAlertSseController.java | 17 +++-- .../domain/bus/api/BusRestController.java | 22 ++++-- .../application/BusArrivalInfoService.java | 52 -------------- .../bus/application/BusArrivalService.java | 67 +++++++++++++++++++ .../application/BusOcrMatchingService.java | 67 ++++++++++--------- .../domain/bus/application/BusService.java | 60 +++++++++-------- .../application/BusStationAssignService.java | 53 --------------- .../bus/application/BusStationService.java | 50 ++++++++++++++ .../domain/bus/converter/BusConverter.java | 25 +------ .../domain/bus/dto/BusArrivalDto.java | 16 +++++ .../domain/bus/dto/BusStationDto.java | 16 +++++ .../domain/bus/entity/BusArrival.java | 37 ---------- .../domain/bus/entity/BusStation.java | 49 -------------- .../bus/repository/BusArrivalRepository.java | 9 --- .../bus/repository/BusStationRepository.java | 12 ---- 15 files changed, 243 insertions(+), 309 deletions(-) delete mode 100644 src/main/java/server/gooroomi/domain/bus/application/BusArrivalInfoService.java create mode 100644 src/main/java/server/gooroomi/domain/bus/application/BusArrivalService.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/application/BusStationAssignService.java create mode 100644 src/main/java/server/gooroomi/domain/bus/application/BusStationService.java create mode 100644 src/main/java/server/gooroomi/domain/bus/dto/BusArrivalDto.java create mode 100644 src/main/java/server/gooroomi/domain/bus/dto/BusStationDto.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/entity/BusArrival.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/entity/BusStation.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/repository/BusArrivalRepository.java delete mode 100644 src/main/java/server/gooroomi/domain/bus/repository/BusStationRepository.java diff --git a/src/main/java/server/gooroomi/domain/bus/api/BusAlertSseController.java b/src/main/java/server/gooroomi/domain/bus/api/BusAlertSseController.java index 6aa7814..eaa10d2 100644 --- a/src/main/java/server/gooroomi/domain/bus/api/BusAlertSseController.java +++ b/src/main/java/server/gooroomi/domain/bus/api/BusAlertSseController.java @@ -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 @@ -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); } } } diff --git a/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java b/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java index c114f98..05974ae 100644 --- a/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java +++ b/src/main/java/server/gooroomi/domain/bus/api/BusRestController.java @@ -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> 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 processOcrResult(@RequestBody OcrProcessRequest request) { return busOcrMatchingService.processOcrResult(request); diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusArrivalInfoService.java b/src/main/java/server/gooroomi/domain/bus/application/BusArrivalInfoService.java deleted file mode 100644 index a478dc9..0000000 --- a/src/main/java/server/gooroomi/domain/bus/application/BusArrivalInfoService.java +++ /dev/null @@ -1,52 +0,0 @@ -package server.gooroomi.domain.bus.application; - -import lombok.RequiredArgsConstructor; -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.converter.BusConverter; -import server.gooroomi.domain.bus.entity.BusArrival; -import server.gooroomi.domain.bus.entity.BusStation; -import server.gooroomi.domain.bus.repository.BusArrivalRepository; - -import java.util.List; -import java.util.Objects; -import java.util.stream.IntStream; - -@Service -@RequiredArgsConstructor -public class BusArrivalInfoService { - - private final BusInfoApiClient busInfoApiClient; - private final BusArrivalRepository busArrivalRepository; - - public void saveBusArrivalInfo(String arsId, BusStation busStation) { - String json = busInfoApiClient.getBusArrivals(arsId); - busStation.getBusArrivals().clear(); - - JSONObject root = new JSONObject(json); - JSONArray itemList = root.getJSONObject("msgBody").optJSONArray("itemList"); - - List 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); - - if ("운행종료".equals(arrmsg1) || "출발대기".equals(arrmsg1) || arrivalInSeconds > 90) { - return null; - } - - BusArrival busArrival = BusConverter.toBusArrival(busNumber, arrivalTime); - busArrival.assignBusStation(busStation); - return busArrival; - }) - .filter(Objects::nonNull) - .toList(); - - busArrivalRepository.saveAll(busArrivals); - } -} diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusArrivalService.java b/src/main/java/server/gooroomi/domain/bus/application/BusArrivalService.java new file mode 100644 index 0000000..cc0aaad --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/application/BusArrivalService.java @@ -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 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 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; + } +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java b/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java index 4d97fde..988c929 100644 --- a/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java +++ b/src/main/java/server/gooroomi/domain/bus/application/BusOcrMatchingService.java @@ -5,13 +5,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import server.gooroomi.domain.bus.converter.BusConverter; +import server.gooroomi.domain.bus.dto.BusArrivalDto; +import server.gooroomi.domain.bus.dto.BusStationDto; import server.gooroomi.domain.bus.dto.OcrProcessRequest; import server.gooroomi.domain.bus.dto.OcrProcessResponse; -import server.gooroomi.domain.bus.entity.BusArrival; -import server.gooroomi.domain.bus.entity.BusStation; -import server.gooroomi.domain.bus.entity.MatchType; +import server.gooroomi.domain.bus.MatchType; +import server.gooroomi.domain.user.application.UserService; import server.gooroomi.domain.user.entity.User; -import server.gooroomi.domain.user.repository.UserRepository; import server.gooroomi.global.handler.response.BaseException; import server.gooroomi.global.handler.response.BaseResponse; import server.gooroomi.global.handler.response.BaseResponseStatus; @@ -20,13 +20,18 @@ import java.util.List; import java.util.Optional; +/** + * OCR 버스 번호 매칭 서비스 + */ @Service @RequiredArgsConstructor @Slf4j public class BusOcrMatchingService { - private final UserRepository userRepository; + private final UserService userService; private final StringSimilarityService similarityService; + private final BusStationService busStationService; + private final BusArrivalService busArrivalService; private static final double SIMILARITY_THRESHOLD = 0.8; // 유사도 임계값 /** @@ -34,13 +39,27 @@ public class BusOcrMatchingService { */ @Transactional public BaseResponse processOcrResult(OcrProcessRequest request) { - // 사용자 및 버스 정류장 조회 - BusStation busStation = getUserBusStation(request.getUserId()); - List busArrivals = busStation.getBusArrivals(); + log.info("[OCR 처리 요청] userId: {}, ocrText: {}", request.getUserId(), request.getOcrText()); + + // 사용자 조회 + User user = userService.getUserById(request.getUserId()); + + // 위치 정보 확인 + if (user.getLatitude() == null || user.getLongitude() == null) { + throw new BaseException(BaseResponseStatus.LOCATION_NOT_REGISTERED); + } + + // 가장 가까운 정류장 조회 + BusStationDto stationDto = busStationService.findNearestStation(user.getLatitude(), user.getLongitude()); + + // 도착 예정 버스 목록 조회 + List busArrivals = busArrivalService.getBusArrivals(stationDto.getArsId()); // 정확히 일치하는 버스 번호 찾기 Optional exactMatchResponse = findExactMatchResponse(busArrivals, request.getOcrText()); if (exactMatchResponse.isPresent()) { + log.info("정확히 일치하는 버스 번호 찾음 - userId: {}, ocrText: {}, busNumber: {}", user.getId(), request.getOcrText(), + exactMatchResponse.get().getProccessedBusNumber()); return BaseResponse.success(exactMatchResponse.get()); } @@ -51,33 +70,20 @@ public BaseResponse processOcrResult(OcrProcessRequest reque } /* - 일치하는 버스가 없는 경우 (정확히 일치하지도 않고, 유사하지도 않은 경우) - 다음 경우가 포함됨 - 1. 버스 목록이 비어있는 경우 - 2. 유사도가 임계값보다 낮은 경우 + * 일치하는 버스가 없는 경우 (정확히 일치하지도 않고, 유사하지도 않은 경우) 다음 경우가 포함됨 + * 1. 버스 목록이 비어있는 경우 + * 2. 유사도가 임계값보다 낮은 경우 */ OcrProcessResponse response = BusConverter.toOCRProcessResponse(request.getOcrText(), request.getOcrText(), MatchType.NONE); - return BaseResponse.success(response); - } - /** - * 사용자 ID로 버스 정류장 정보 조회 - */ - private BusStation getUserBusStation(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); - BusStation busStation = user.getBusStation(); - if (busStation == null) { - throw new BaseException(BaseResponseStatus.NOT_FOUND_STATION); - } - return busStation; + return BaseResponse.success(response); } /** * 정확히 일치하는 버스 번호 찾기 */ - private Optional findExactMatchResponse(List busArrivals, String ocrText) { + private Optional findExactMatchResponse(List busArrivals, String ocrText) { return busArrivals.stream().filter(arrival -> arrival.getBusNumber().equals(ocrText)).findFirst() .map(arrival -> { String busNumber = arrival.getBusNumber(); @@ -88,8 +94,8 @@ private Optional findExactMatchResponse(List bus /** * 유사한 버스 번호 찾기 유사도가 임계값 이상인 경우에만 결과를 반환하고, 그렇지 않은 경우에는 Optional.empty()를 반환 */ - private Optional findSimilarMatchResponse(List busArrivals, String ocrText) { - Optional mostSimilarBus = findMostSimilarBus(busArrivals, ocrText); + private Optional findSimilarMatchResponse(List busArrivals, String ocrText) { + Optional mostSimilarBus = findMostSimilarBus(busArrivals, ocrText); if (mostSimilarBus.isPresent()) { String mostSimilarBusNumber = mostSimilarBus.get().getBusNumber(); @@ -102,7 +108,6 @@ private Optional findSimilarMatchResponse(List b return Optional.of(BusConverter.toOCRProcessResponse(mostSimilarBusNumber, ocrText, MatchType.SIMILAR)); } } - // 유사도가 임계값 미만이거나 버스가 없는 경우 return Optional.empty(); } @@ -110,7 +115,7 @@ private Optional findSimilarMatchResponse(List b /** * 가장 유사한 버스 찾기 */ - private Optional findMostSimilarBus(List busArrivals, String ocrText) { + private Optional findMostSimilarBus(List busArrivals, String ocrText) { return busArrivals.stream().max(Comparator.comparingDouble( arrival -> similarityService.calculateJaroWinklerSimilarity(arrival.getBusNumber(), ocrText))); } @@ -119,6 +124,6 @@ private Optional findMostSimilarBus(List busArrivals, St * 유사도 정보 로깅 */ private void logSimilarityInfo(String ocrText, String mostSimilarBusNumber, double similarity) { - log.info("OCR 결과: {}, 가장 유사한 버스 번호: {}, 유사도: {}", ocrText, mostSimilarBusNumber, similarity); + log.info("[유사도 매칭 결과] ocrText: {}, 가장 유사한 버스 번호: {}, 유사도: {}", ocrText, mostSimilarBusNumber, similarity); } } diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusService.java b/src/main/java/server/gooroomi/domain/bus/application/BusService.java index ec3068c..3a1c0f7 100644 --- a/src/main/java/server/gooroomi/domain/bus/application/BusService.java +++ b/src/main/java/server/gooroomi/domain/bus/application/BusService.java @@ -1,13 +1,13 @@ package server.gooroomi.domain.bus.application; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import server.gooroomi.domain.bus.converter.BusConverter; +import server.gooroomi.domain.bus.dto.BusArrivalDto; import server.gooroomi.domain.bus.dto.BusArrivalResponse; -import server.gooroomi.domain.bus.entity.BusArrival; -import server.gooroomi.domain.bus.entity.BusStation; +import server.gooroomi.domain.bus.dto.BusStationDto; +import server.gooroomi.domain.user.application.UserService; import server.gooroomi.domain.user.entity.User; -import server.gooroomi.domain.user.repository.UserRepository; import server.gooroomi.global.handler.response.BaseException; import server.gooroomi.global.handler.response.BaseResponse; import server.gooroomi.global.handler.response.BaseResponseStatus; @@ -15,47 +15,53 @@ import java.util.List; import java.util.stream.Collectors; +/** + * 버스 서비스 사용자 위치 기반으로 버스 도착 정보를 조회 + */ @Service @RequiredArgsConstructor +@Slf4j public class BusService { - private final UserRepository userRepository; + private final BusStationService busStationService; + private final BusArrivalService busArrivalService; + private final BusAlertService busAlertService; + private final UserService userService; /** * 사용자 ID를 기반으로 버스 도착 정보 조회 */ public BaseResponse> getBusArrivals(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); + // 사용자 조회 + User user = userService.getUserById(userId); - BusStation busStation = getBusStationFromUser(user); - String userBusNumber = user.getBusNumber(); + // 위치 정보 확인 + if (user.getLatitude() == null || user.getLongitude() == null) { + throw new BaseException(BaseResponseStatus.LOCATION_NOT_REGISTERED); + } - // 버스 도착 정보 조회 및 변환 - List busArrivals = busStation.getBusArrivals(); - List responseList = busArrivals.stream().map(BusConverter::toBusArrivalResponse) - .collect(Collectors.toList()); + // 가장 가까운 정류장 조회 + BusStationDto stationDto = busStationService.findNearestStation(user.getLatitude(), user.getLongitude()); - // 사용자 버스 도착 여부에 따른 응답 - return getBusArrivalResponse(userBusNumber, busArrivals, responseList); - } + // 도착 예정 버스 목록 조회 + List busArrivals = busArrivalService.getBusArrivals(stationDto.getArsId()); - /** - * 사용자 버스 정류소 정보 조회 - */ - private BusStation getBusStationFromUser(User user) { - BusStation busStation = user.getBusStation(); - if (busStation == null) { - throw new BaseException(BaseResponseStatus.LOCATION_NOT_UPDATED); - } - return busStation; + // 알림 서비스에 버스 도착 정보 전달 + busAlertService.notifyUserIfBusArriving(userId, busArrivals); + + // 응답 생성 + List responseList = busArrivals.stream() + .map(dto -> new BusArrivalResponse(dto.getBusNumber())).collect(Collectors.toList()); + + // 사용자 버스 도착 여부에 따른 응답 + return getBusArrivalResponse(user.getBusNumber(), busArrivals, responseList); } /** * 사용자가 등록한 버스의 도착 여부에 따라 다른 응답 */ private BaseResponse> getBusArrivalResponse(String userBusNumber, - List busArrivals, List responseList) { + List busArrivals, List responseList) { // 사용자가 등록한 버스가 도착 예정 버스 목록에 있는지 확인 boolean isUserBusArriving = busArrivals.stream() @@ -78,7 +84,7 @@ private BaseResponse> getBusArrivalResponse(String user /** * 도착 예정인 버스가 사용자의 버스 1대만 있는지 확인 */ - private boolean isSingleUserBusArriving(List busArrivals, String userBusNumber) { + private boolean isSingleUserBusArriving(List busArrivals, String userBusNumber) { return busArrivals.size() == 1 && busArrivals.get(0).getBusNumber().equals(userBusNumber); } } diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusStationAssignService.java b/src/main/java/server/gooroomi/domain/bus/application/BusStationAssignService.java deleted file mode 100644 index a917978..0000000 --- a/src/main/java/server/gooroomi/domain/bus/application/BusStationAssignService.java +++ /dev/null @@ -1,53 +0,0 @@ -package server.gooroomi.domain.bus.application; - -import lombok.RequiredArgsConstructor; -import org.json.JSONArray; -import org.json.JSONObject; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import server.gooroomi.domain.bus.api.StationInfoApiClient; -import server.gooroomi.domain.bus.converter.BusConverter; -import server.gooroomi.domain.bus.entity.BusStation; -import server.gooroomi.domain.bus.repository.BusStationRepository; -import server.gooroomi.domain.user.entity.User; -import server.gooroomi.global.handler.response.BaseException; -import server.gooroomi.global.handler.response.BaseResponseStatus; - -@Service -@RequiredArgsConstructor -public class BusStationAssignService { - - private final StationInfoApiClient stationInfoApiClient; - private final BusStationRepository busStationRepository; - private final BusArrivalInfoService busArrivalInfoService; - - @Value("${bus.station.search-radius}") - private int searchRadius; - - public void saveBusStation(User user) { - String json = stationInfoApiClient.getNearbyStations(user.getLongitude(), user.getLatitude(), searchRadius); - - JSONObject root = new JSONObject(json); - JSONArray itemList = root.getJSONObject("msgBody").optJSONArray("itemList"); - - if (itemList == null || itemList.isEmpty()) { - throw new BaseException(BaseResponseStatus.STATION_NOT_FOUND); - } - - JSONObject nearest = itemList.getJSONObject(0); - String arsId = nearest.getString("arsId"); - String stationNm = nearest.getString("stationNm"); - - BusStation busStation = user.getBusStation(); - - if (busStation == null) { - busStation = BusConverter.toBusStation(arsId, stationNm); - busStation.assignUserBusStation(user); - busStationRepository.save(busStation); - } else if (!busStation.getArsId().equals(arsId)) { - busStation.updateBusStationInfo(arsId, stationNm); - } - - busArrivalInfoService.saveBusArrivalInfo(arsId, busStation); - } -} diff --git a/src/main/java/server/gooroomi/domain/bus/application/BusStationService.java b/src/main/java/server/gooroomi/domain/bus/application/BusStationService.java new file mode 100644 index 0000000..e294a18 --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/application/BusStationService.java @@ -0,0 +1,50 @@ +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.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import server.gooroomi.domain.bus.api.StationInfoApiClient; +import server.gooroomi.domain.bus.dto.BusStationDto; +import server.gooroomi.global.handler.response.BaseException; +import server.gooroomi.global.handler.response.BaseResponseStatus; + +/** + * 버스 정류소 검색 서비스 + * 위치 정보를 기반으로 가장 가까운 정류소 조회 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BusStationService { + + private final StationInfoApiClient stationInfoApiClient; + + @Value("${bus.station.search-radius}") + private int searchRadius; + + /** + * 위치 정보(위도, 경도)를 기반으로 가장 가까운 정류소 조회 + */ + public BusStationDto findNearestStation(Double latitude, Double longitude) { + String json = stationInfoApiClient.getNearbyStations(longitude, latitude, searchRadius); + + JSONObject root = new JSONObject(json); + JSONArray itemList = root.getJSONObject("msgBody").optJSONArray("itemList"); + + if (itemList == null || itemList.isEmpty()) { + throw new BaseException(BaseResponseStatus.STATION_NOT_FOUND); + } + + // 가장 가까운 정류소 정보 추출 + JSONObject nearest = itemList.getJSONObject(0); + String arsId = nearest.getString("arsId"); + String stationName = nearest.getString("stationNm"); + + log.info("[가장 가까운 정류소] arsId={}, stationName={}", arsId, stationName); + + return new BusStationDto(arsId, stationName); + } +} diff --git a/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java b/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java index 5c8a3c5..14a9862 100644 --- a/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java +++ b/src/main/java/server/gooroomi/domain/bus/converter/BusConverter.java @@ -1,32 +1,9 @@ package server.gooroomi.domain.bus.converter; -import server.gooroomi.domain.bus.dto.BusArrivalResponse; import server.gooroomi.domain.bus.dto.OcrProcessResponse; -import server.gooroomi.domain.bus.entity.BusArrival; -import server.gooroomi.domain.bus.entity.BusStation; -import server.gooroomi.domain.bus.entity.MatchType; +import server.gooroomi.domain.bus.MatchType; public class BusConverter { - public static BusStation toBusStation(String arsId, String stationNm) { - return BusStation.builder() - .arsId(arsId) - .stationName(stationNm) - .build(); - } - - public static BusArrival toBusArrival(String busNumber, String arrivalTime) { - return BusArrival.builder() - .busNumber(busNumber) - .arrivalTime(arrivalTime) - .build(); - } - - public static BusArrivalResponse toBusArrivalResponse(BusArrival busArrival) { - return BusArrivalResponse.builder() - .busNumber(busArrival.getBusNumber()) - .build(); - } - public static OcrProcessResponse toOCRProcessResponse(String proccessedBusNumber, String rawOcrText, MatchType type) { return OcrProcessResponse.builder() .proccessedBusNumber(proccessedBusNumber) diff --git a/src/main/java/server/gooroomi/domain/bus/dto/BusArrivalDto.java b/src/main/java/server/gooroomi/domain/bus/dto/BusArrivalDto.java new file mode 100644 index 0000000..6d6a93c --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/dto/BusArrivalDto.java @@ -0,0 +1,16 @@ +package server.gooroomi.domain.bus.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 버스 도착 정보를 담는 DTO 실시간 데이터 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BusArrivalDto { + private String busNumber; // 버스 번호 + private String arrivalTime; // 도착 예정 시간 (초) +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/dto/BusStationDto.java b/src/main/java/server/gooroomi/domain/bus/dto/BusStationDto.java new file mode 100644 index 0000000..ba3c5f2 --- /dev/null +++ b/src/main/java/server/gooroomi/domain/bus/dto/BusStationDto.java @@ -0,0 +1,16 @@ +package server.gooroomi.domain.bus.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 버스 정류소 정보를 담는 DTO 실시간 데이터 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BusStationDto { + private String arsId; // 정류소 번호 + private String stationName; // 정류소명 +} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/entity/BusArrival.java b/src/main/java/server/gooroomi/domain/bus/entity/BusArrival.java deleted file mode 100644 index 46525e9..0000000 --- a/src/main/java/server/gooroomi/domain/bus/entity/BusArrival.java +++ /dev/null @@ -1,37 +0,0 @@ -package server.gooroomi.domain.bus.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import server.gooroomi.domain.user.entity.BaseTimeEntity; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -public class BusArrival extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "bus_arrival_id") - private Long id; - - private String busNumber; // 버스 번호 - private String arrivalTime; // 도착 시간 (초) - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "bus_station_id") - private BusStation busStation; - - @Builder - public BusArrival(String busNumber, String arrivalTime) { - this.busNumber = busNumber; - this.arrivalTime = arrivalTime; - } - - public void assignBusStation(BusStation busStation) { - this.busStation = busStation; - busStation.getBusArrivals().add(this); - } -} diff --git a/src/main/java/server/gooroomi/domain/bus/entity/BusStation.java b/src/main/java/server/gooroomi/domain/bus/entity/BusStation.java deleted file mode 100644 index bf8a214..0000000 --- a/src/main/java/server/gooroomi/domain/bus/entity/BusStation.java +++ /dev/null @@ -1,49 +0,0 @@ -package server.gooroomi.domain.bus.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import server.gooroomi.domain.user.entity.BaseTimeEntity; -import server.gooroomi.domain.user.entity.User; - -import java.util.ArrayList; -import java.util.List; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Entity -public class BusStation extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "bus_station_id") - private Long id; - - private String arsId; // 정류소 번호 - private String stationName; // 정류소명 - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - @OneToMany(mappedBy = "busStation", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) - private List busArrivals = new ArrayList<>(); - - @Builder - public BusStation(String arsId, String stationName) { - this.arsId = arsId; - this.stationName = stationName; - } - - public void assignUserBusStation(User user){ - this.user = user; - user.assignBusStation(this); - } - - public void updateBusStationInfo(String arsId, String stationName) { - this.arsId = arsId; - this.stationName = stationName; - } -} \ No newline at end of file diff --git a/src/main/java/server/gooroomi/domain/bus/repository/BusArrivalRepository.java b/src/main/java/server/gooroomi/domain/bus/repository/BusArrivalRepository.java deleted file mode 100644 index b666621..0000000 --- a/src/main/java/server/gooroomi/domain/bus/repository/BusArrivalRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package server.gooroomi.domain.bus.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import server.gooroomi.domain.bus.entity.BusArrival; - -@Repository -public interface BusArrivalRepository extends JpaRepository { -} diff --git a/src/main/java/server/gooroomi/domain/bus/repository/BusStationRepository.java b/src/main/java/server/gooroomi/domain/bus/repository/BusStationRepository.java deleted file mode 100644 index fa18425..0000000 --- a/src/main/java/server/gooroomi/domain/bus/repository/BusStationRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package server.gooroomi.domain.bus.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import server.gooroomi.domain.bus.entity.BusStation; - -import java.util.Optional; - -@Repository -public interface BusStationRepository extends JpaRepository { - Optional findByUserId(Long userId); -} From 0d4920bc310ad4a09e7e395fa882b01ab300ef0a Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:29:30 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[chore/#41]=20open-in-view=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20ddl-auto=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 6 +----- src/main/resources/common.yml | 6 +++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 74b2348..fa5ecf0 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -6,7 +6,7 @@ spring: password: ${LOCAL_DB_PASSWORD} jpa: hibernate: - ddl-auto: update + ddl-auto: create show-sql: true logging: @@ -17,10 +17,6 @@ openapi: bus: key: ${OPEN_API_KEY} -bus: - station: - search-radius: 300 - management: endpoint: health: diff --git a/src/main/resources/common.yml b/src/main/resources/common.yml index 45dce4c..8e2194a 100644 --- a/src/main/resources/common.yml +++ b/src/main/resources/common.yml @@ -20,4 +20,8 @@ management: bus: station: - search-radius: ${BUS_STATION_SEARCH_RADIUS} \ No newline at end of file + search-radius: ${BUS_STATION_SEARCH_RADIUS} + +spring: + jpa: + open-in-view: false \ No newline at end of file From a559ab4cbdaabc6284d8fde8eee8f0f3c4b5806b Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:30:14 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[refactor/#41]=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4/=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/api/UserRestController.java | 6 +----- .../domain/user/application/UserService.java | 20 ++++++++++++++----- .../gooroomi/domain/user/entity/User.java | 11 +--------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/main/java/server/gooroomi/domain/user/api/UserRestController.java b/src/main/java/server/gooroomi/domain/user/api/UserRestController.java index d202e63..42e781e 100644 --- a/src/main/java/server/gooroomi/domain/user/api/UserRestController.java +++ b/src/main/java/server/gooroomi/domain/user/api/UserRestController.java @@ -1,7 +1,6 @@ package server.gooroomi.domain.user.api; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -28,10 +27,7 @@ public BaseResponse saveUserBusInfo(@RequestBody UserBusN return userService.saveUserBusInfo(requestDto); } - @Operation( - summary = "사용자 위치 정보 저장", - description = "사용자 위치를 저장하고, 가장 가까운 정류소 및 도착 버스를 저장합니다.\n" + - "근처에 버스 정류장이 없을 경우 code: 20001 응답 반환.") + @Operation(summary = "사용자 위치 정보 저장", description = "사용자 위치 정보를 등록합니다.") @PostMapping("/location") public BaseResponse saveUserLocation(@RequestBody UserLocationRequest requestDto) { return userService.saveUserLocation(requestDto); diff --git a/src/main/java/server/gooroomi/domain/user/application/UserService.java b/src/main/java/server/gooroomi/domain/user/application/UserService.java index e4e5c1b..2a3e0ed 100644 --- a/src/main/java/server/gooroomi/domain/user/application/UserService.java +++ b/src/main/java/server/gooroomi/domain/user/application/UserService.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import server.gooroomi.domain.bus.application.BusStationAssignService; import server.gooroomi.domain.user.converter.UserConverter; import server.gooroomi.domain.user.dto.UserBusNumberRequest; import server.gooroomi.domain.user.dto.UserBusNumberResponse; @@ -19,8 +18,10 @@ public class UserService { private final UserRepository userRepository; - private final BusStationAssignService busStationAssignService; + /** + * 사용자 버스 번호 저장 + */ @Transactional public BaseResponse saveUserBusInfo(UserBusNumberRequest requestDto) { User user = UserConverter.toUserEntity(requestDto); @@ -29,12 +30,21 @@ public BaseResponse saveUserBusInfo(UserBusNumberRequest return BaseResponse.success(response); } + /** + * 사용자 위치 정보 저장 + */ @Transactional public BaseResponse saveUserLocation(UserLocationRequest requestDto) { - User user = userRepository.findById(requestDto.getUserId()) - .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); + User user = getUserById(requestDto.getUserId()); user.updateLocation(requestDto.getLatitude(), requestDto.getLongitude()); - busStationAssignService.saveBusStation(user); return BaseResponse.success(); } + + /** + * 사용자 조회 + */ + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); + } } diff --git a/src/main/java/server/gooroomi/domain/user/entity/User.java b/src/main/java/server/gooroomi/domain/user/entity/User.java index 6276d90..f430e6a 100644 --- a/src/main/java/server/gooroomi/domain/user/entity/User.java +++ b/src/main/java/server/gooroomi/domain/user/entity/User.java @@ -1,12 +1,10 @@ package server.gooroomi.domain.user.entity; - import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import server.gooroomi.domain.bus.entity.BusStation; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -19,13 +17,10 @@ public class User extends BaseTimeEntity { @Column(name = "user_id") private Long id; - private Double latitude; // 위도 (Y) + private Double latitude; // 위도 (Y) private Double longitude; // 경도 (X) private String busNumber; // 사용자가 탑승하고자 하는 버스 번호 - @OneToOne(mappedBy = "user") - private BusStation busStation; - @Builder public User(String busNumber) { this.busNumber = busNumber; @@ -35,8 +30,4 @@ public void updateLocation(Double latitude, Double longitude) { this.latitude = latitude; this.longitude = longitude; } - - public void assignBusStation(BusStation busStation) { - this.busStation = busStation; - } } From a0910f8e4a082ca4e381a7b89a0b4048357b8052 Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:31:41 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[file/#41]=20=ED=8F=B4=EB=8D=94=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java index 0a8b4bc..f20605f 100644 --- a/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java +++ b/src/main/java/server/gooroomi/domain/bus/dto/OcrProcessResponse.java @@ -4,7 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import server.gooroomi.domain.bus.entity.MatchType; +import server.gooroomi.domain.bus.MatchType; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) From aa270508600a799797edb40097e667a77bb82a2d Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:32:21 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[refactor/#41]=20LocationWebSocketHandler?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/LocationWebSocketHandler.java | 130 ++++++------------ 1 file changed, 41 insertions(+), 89 deletions(-) diff --git a/src/main/java/server/gooroomi/global/handler/LocationWebSocketHandler.java b/src/main/java/server/gooroomi/global/handler/LocationWebSocketHandler.java index 8678ff3..30d82b3 100644 --- a/src/main/java/server/gooroomi/global/handler/LocationWebSocketHandler.java +++ b/src/main/java/server/gooroomi/global/handler/LocationWebSocketHandler.java @@ -3,52 +3,43 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.json.JSONArray; -import org.json.JSONObject; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; -import server.gooroomi.domain.bus.api.BusInfoApiClient; -import server.gooroomi.domain.bus.api.StationInfoApiClient; -import server.gooroomi.domain.bus.application.SseEmitterService; -import server.gooroomi.domain.bus.converter.BusConverter; -import server.gooroomi.domain.bus.entity.BusArrival; -import server.gooroomi.domain.bus.entity.BusStation; -import server.gooroomi.domain.bus.repository.BusStationRepository; +import server.gooroomi.domain.bus.application.BusAlertService; +import server.gooroomi.domain.bus.application.BusArrivalService; +import server.gooroomi.domain.bus.application.BusStationService; +import server.gooroomi.domain.bus.dto.BusArrivalDto; +import server.gooroomi.domain.bus.dto.BusStationDto; +import server.gooroomi.domain.user.application.UserService; import server.gooroomi.domain.user.entity.User; -import server.gooroomi.domain.user.repository.UserRepository; import server.gooroomi.global.dto.WebSocketDto.LocationDto; import server.gooroomi.global.handler.response.BaseException; import server.gooroomi.global.handler.response.BaseResponseStatus; import java.util.List; -import java.util.Objects; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.IntStream; +/** + * 위치 정보 WebSocket 핸들러 클라이언트로부터 위치 정보를 수신하고 버스 도착 정보를 처리하는 역할 담당 + */ @RequiredArgsConstructor @Component @Slf4j public class LocationWebSocketHandler extends TextWebSocketHandler { private final ObjectMapper objectMapper = new ObjectMapper(); - private final UserRepository userRepository; - private final StationInfoApiClient stationInfoApiClient; - private final BusInfoApiClient busInfoApiClient; - private final BusStationRepository busStationRepository; - private final SseEmitterService sseEmitterService; + private final UserService userService; + private final BusStationService busStationService; + private final BusArrivalService busArrivalService; + private final BusAlertService busAlertService; // 연결된 WebSocket 세션을 저장하는 Map private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); - @Value("${bus.station.search-radius}") - private int searchRadius; - // 클라이언트와 WebSocket 연결이 수립되었을 때 호출됨 @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { @@ -61,74 +52,35 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio @Transactional protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // JSON 메시지를 DTO로 변환 - LocationDto latLng = objectMapper.readValue(message.getPayload(), LocationDto.class); - log.info("받은 위치: 위도 = {}, 경도 = {}", latLng.getLatitude(), latLng.getLongitude()); - - // 사용자 조회 - User user = userRepository.findById(latLng.getUserId()) - .orElseThrow(() -> new BaseException(BaseResponseStatus.NOT_FOUND_USER)); - - // 사용자 위치 업데이트 - user.updateLocation(latLng.getLatitude(), latLng.getLongitude()); - userRepository.save(user); - - // 사용자 위치 기반으로 가장 가까운 정류장 조회 - String stationJson = stationInfoApiClient.getNearbyStations(latLng.getLongitude(), latLng.getLatitude(), searchRadius); - JSONObject stationRoot = new JSONObject(stationJson); - JSONArray stationList = stationRoot.getJSONObject("msgBody").optJSONArray("itemList"); - - if (stationList == null || stationList.isEmpty()) { - // 정류장이 없을 경우 사용자에게 알림 전송 - session.sendMessage(new TextMessage("가까운 정류장이 없습니다.")); - return; + LocationDto locationDto = objectMapper.readValue(message.getPayload(), LocationDto.class); + log.info("받은 위치: 위도 = {}, 경도 = {}", locationDto.getLatitude(), locationDto.getLongitude()); + + try { + // 사용자 조회 및 위치 업데이트 + User user = userService.getUserById(locationDto.getUserId()); + user.updateLocation(locationDto.getLatitude(), locationDto.getLongitude()); + + // 가장 가까운 정류장 조회 + BusStationDto stationDto = busStationService.findNearestStation(locationDto.getLatitude(), + locationDto.getLongitude()); + + // 도착 예정 버스 목록 조회 + List busArrivals = busArrivalService.getBusArrivals(stationDto.getArsId()); + + // 사용자에게 버스 도착 알림 전송 + busAlertService.notifyUserIfBusArriving(user.getId(), busArrivals); + + // 정류장 정보를 클라이언트에게 응답 + session.sendMessage( + new TextMessage("정류장 정보: " + stationDto.getStationName() + " (" + stationDto.getArsId() + ")")); + + } catch (BaseException e) { + if (e.getStatus() == BaseResponseStatus.STATION_NOT_FOUND) { + session.sendMessage(new TextMessage("가까운 정류장이 없습니다.")); + } else { + throw e; + } } - - // 가장 가까운 정류장의 arsId, stationName 추출 - JSONObject nearestStation = stationList.getJSONObject(0); - String arsId = nearestStation.getString("arsId"); - String stationNm = nearestStation.getString("stationNm"); - - // 해당 arsId로 도착 예정 버스 정보 조회 - String arrivalJson = busInfoApiClient.getBusArrivals(arsId); - JSONObject arrivalRoot = new JSONObject(arrivalJson); - JSONArray arrivalList = arrivalRoot.getJSONObject("msgBody").optJSONArray("itemList"); - - // 도착 예정 버스 목록 중 120초 이내 도착하는 버스를 필터링하여 엔티티로 변환 - List busArrivals = IntStream.range(0, arrivalList.length()) - .mapToObj(i -> { - JSONObject item = arrivalList.getJSONObject(i); - String busNumber = item.getString("rtNm"); - String arrivalTime = item.getString("traTime1"); - String arrmsg1 = item.getString("arrmsg1"); - int seconds = Integer.parseInt(arrivalTime); - if ("운행종료".equals(arrmsg1) || "출발대기".equals(arrmsg1) || seconds > 120) return null; - BusArrival busArrival = BusConverter.toBusArrival(busNumber, arrivalTime); - return busArrival; - }) - .filter(Objects::nonNull) - .toList(); - - Optional existing = busStationRepository.findByUserId(user.getId()); - - BusStation busStation; - if (existing.isPresent()) { - // 기존 정류장 update - busStation = existing.get(); - busStation.updateBusStationInfo(arsId, stationNm); - busStation.getBusArrivals().clear(); - } else { - // 새로 생성 - busStation = BusConverter.toBusStation(arsId, stationNm); - busStation.assignUserBusStation(user); - } - - // 버스 도착 정보 추가 - busArrivals.forEach(arrival -> arrival.assignBusStation(busStation)); - busStation.getBusArrivals().addAll(busArrivals); - busStationRepository.save(busStation); - - // SSE 구독 중인 사용자에게 도착 알림 전송 (사용자가 등록한 버스가 포함된 경우) - sseEmitterService.notifyUserIfBusArriving(user.getId(), busArrivals); } // WebSocket 연결이 종료되었을 때 호출됨 From 888084632569596f8658365922a74c53cad0c3ac Mon Sep 17 00:00:00 2001 From: so1eeee Date: Thu, 5 Jun 2025 22:32:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[refactor/#41]=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=20=EC=9C=A0=EC=82=AC=EB=8F=84=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/StringSimilarityService.java | 117 +++++++----------- 1 file changed, 46 insertions(+), 71 deletions(-) diff --git a/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java b/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java index 85b29f3..46d349d 100644 --- a/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java +++ b/src/main/java/server/gooroomi/domain/bus/application/StringSimilarityService.java @@ -1,118 +1,93 @@ package server.gooroomi.domain.bus.application; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** - * 문자열 유사도 계산을 위한 Service 클래스 - * Jaro-Winkler 알고리즘을 사용하여 문자열 간의 유사도를 계산 + * 문자열 유사도 계산 서비스 */ @Service -@Slf4j public class StringSimilarityService { - private static final double JARO_WINKLER_PREFIX_WEIGHT = 0.1; // Jaro-Winkler 접두사 가중치 - /** - * Jaro-Winkler 유사도 계산 알고리즘 구현 문자열 간의 유사성을 측정하며, 특히 시작 부분이 일치할 때 더 높은 점수를 부여함 - * - * @param s1 첫 번째 문자열 - * @param s2 두 번째 문자열 - * @return 두 문자열 간의 Jaro-Winkler 유사도 (0.0 ~ 1.0) + * Jaro-Winkler 유사도 계산 0.0 (완전히 다름) ~ 1.0 (완전히 같음) */ public double calculateJaroWinklerSimilarity(String s1, String s2) { - // 두 문자열이 같으면 유사도는 1.0 - if (s1.equals(s2)) { - return 1.0; + // 빈 문자열 처리 + if (s1 == null || s2 == null) { + return 0.0; } - - // 두 문자열 중 하나라도 비어있으면 유사도는 0.0 - if (s1 == null || s2 == null || s1.isEmpty() || s2.isEmpty()) { + if (s1.isEmpty() || s2.isEmpty()) { return 0.0; } - // Jaro 유사도 계산 - double jaroSimilarity = calculateJaroSimilarity(s1, s2); - - // 공통 접두사 길이 계산 (최대 4자까지) - int prefixLength = 0; - int maxPrefixLength = Math.min(4, Math.min(s1.length(), s2.length())); - - for (int i = 0; i < maxPrefixLength; i++) { - if (s1.charAt(i) == s2.charAt(i)) { - prefixLength++; - } else { - break; - } + // 같은 문자열인 경우 + if (s1.equals(s2)) { + return 1.0; } - // Jaro-Winkler 유사도 계산: Jaro 유사도 + (접두사 길이 * 가중치 * (1 - Jaro 유사도)) - return jaroSimilarity + (prefixLength * JARO_WINKLER_PREFIX_WEIGHT * (1.0 - jaroSimilarity)); - } - - /** - * Jaro 유사도 계산 알고리즘 구현 - * - * @param s1 첫 번째 문자열 - * @param s2 두 번째 문자열 - * @return 두 문자열 간의 Jaro 유사도 (0.0 ~ 1.0) - */ - private double calculateJaroSimilarity(String s1, String s2) { - // 두 문자열의 길이 + // Jaro 유사도 계산 int len1 = s1.length(); int len2 = s2.length(); + int maxDist = Math.max(len1, len2) / 2 - 1; + maxDist = Math.max(0, maxDist); // 최소 0 - // 최대 일치 거리 계산 (두 문자열 길이 중 큰 값 / 2 - 1) - int maxDistance = Math.max(0, Math.max(len1, len2) / 2 - 1); + boolean[] match1 = new boolean[len1]; + boolean[] match2 = new boolean[len2]; // 일치하는 문자 찾기 - boolean[] matched1 = new boolean[len1]; - boolean[] matched2 = new boolean[len2]; - - int matchCount = 0; // 일치하는 문자 수 - + int matches = 0; for (int i = 0; i < len1; i++) { - int start = Math.max(0, i - maxDistance); - int end = Math.min(len2 - 1, i + maxDistance); - - for (int j = start; j <= end; j++) { - if (!matched2[j] && s1.charAt(i) == s2.charAt(j)) { - matched1[i] = true; - matched2[j] = true; - matchCount++; + int start = Math.max(0, i - maxDist); + int end = Math.min(i + maxDist + 1, len2); + + for (int j = start; j < end; j++) { + if (!match2[j] && s1.charAt(i) == s2.charAt(j)) { + match1[i] = true; + match2[j] = true; + matches++; break; } } } - // 일치하는 문자가 없으면 유사도는 0.0 - if (matchCount == 0) { + // 일치하는 문자가 없는 경우 + if (matches == 0) { return 0.0; } - // 전환된 문자 수 계산 + // 전치된 문자 수 계산 int transpositions = 0; int k = 0; - for (int i = 0; i < len1; i++) { - if (matched1[i]) { - while (!matched2[k]) { + if (match1[i]) { + while (!match2[k]) { k++; } - if (s1.charAt(i) != s2.charAt(k)) { transpositions++; } - k++; } } - // 전환은 쌍으로 계산하므로 2로 나눔 - transpositions /= 2; + // Jaro 유사도 계산 + double jaro = ((double) matches / len1 + (double) matches / len2 + + (double) (matches - (transpositions / 2)) / matches) / 3.0; + + // Jaro-Winkler 유사도 계산 (공통 접두사에 가중치 부여) + int prefixLength = 0; + for (int i = 0; i < Math.min(4, Math.min(len1, len2)); i++) { + if (s1.charAt(i) == s2.charAt(i)) { + prefixLength++; + } else { + break; + } + } + + // Winkler 수정 (공통 접두사에 가중치 부여) + double p = 0.1; // 가중치 계수 (일반적으로 0.1 사용) + double result = jaro + prefixLength * p * (1 - jaro); - // Jaro 유사도 계산: (일치 문자 비율 + 일치 문자 비율 + (일치 문자 - 전환) / 일치 문자) / 3 - double m = matchCount; - return (m / len1 + m / len2 + (m - transpositions) / m) / 3.0; + return result; } } \ No newline at end of file