From 505e04e969b75a00328ae492baaddd8eb2221af7 Mon Sep 17 00:00:00 2001 From: JangInho Date: Sun, 24 May 2026 22:18:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EC=A0=88?= =?UTF-8?q?=EB=B0=98=20=EC=A7=80=EC=A0=90=20=EB=8F=84=EB=8B=AC=20=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=95=EC=83=81=20=EC=9D=B8=EC=A6=9D=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/enums/NotificationType.java | 11 +++ ...a => TrackingMilestoneTriggerService.java} | 75 +++++++++++++++++-- .../service/TrackingSessionService.java | 4 +- .../service/TrackingStreamConsumer.java | 4 +- .../V17__update_notifications_type_check.sql | 17 +++++ 5 files changed, 101 insertions(+), 10 deletions(-) rename src/main/java/com/semosan/api/domain/tracking/service/{TrackingPhotoTriggerService.java => TrackingMilestoneTriggerService.java} (63%) create mode 100644 src/main/resources/db/migration/V17__update_notifications_type_check.sql diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 41e43e46..88fa62fc 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -23,6 +23,17 @@ public enum NotificationType { "", "{distance}m 돌파! 인증 사진을 남겨보세요!", Set.of("distance") + ), + + /** + * 코스 거리 50% 도달 시 정상 인증 유도. + * 진짜 정상 좌표가 식별 불가해 코스 절반 지점을 "정상" 근처로 간주하는 임시 정책. + * iOS 잠금화면에 title 은 앱 이름(SEMOSAN)이 자동 표시되므로 본 title 은 빈 문자열. + */ + TRACKING_SUMMIT_REACHED( + "", + "정상에 도착했나요? 정상 인증하기!", + Set.of() ); private final String titleTemplate; diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java similarity index 63% rename from src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java rename to src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java index 7f8f3d8c..36f704a3 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java @@ -19,11 +19,15 @@ import java.util.stream.Collectors; /** - * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리 ±10% 윈도우에 - * 진입/이탈할 때 다음 동작을 수행한다. - * - 진입(OPEN): WebSocket OPEN 메시지 + FCM 푸시 ("{distance}m 돌파! 인증 사진을 남겨보세요!") - * - 이탈(CLOSED): WebSocket CLOSED 메시지 (FCM 없음) - * 마일스톤 도달 상태는 Redis Set 으로 idempotent 하게 관리. + * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리에 도달하면 + * 사용자에게 두 종류의 알림을 트리거한다. + * + * 1) 사진 마일스톤(photo) — 코스 4컷 / 자유 6컷. + * ±10% 윈도우 진입 시 OPEN, 이탈 시 CLOSED. 채널: WebSocket(/topic/.../photo-window) + FCM OPEN 시점만. + * + * 2) 정상 도달(summit) — 코스 거리 50% 지점 도달 시 1회 (자유 기록은 skip). + * 코스 정상 좌표를 정확히 식별 못해 임시 정책으로 "코스 절반" 을 정상 근처로 본다. + * 채널: WebSocket(/topic/.../summit) + FCM. Redis Set 으로 1회 idempotent 보장. * * TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성. * Lua 스크립트 또는 분산 락으로 보강 필요. @@ -32,10 +36,12 @@ @Slf4j @Service @RequiredArgsConstructor -public class TrackingPhotoTriggerService { +public class TrackingMilestoneTriggerService { private static final double TOLERANCE_RATIO = 0.10; private static final Duration TTL = Duration.ofHours(24); + /** 코스 모드의 사진 마일스톤 개수 (1/4, 2/4, 3/4, 4/4). TrackingMilestoneCalculator 와 동기 유지. */ + private static final int COURSE_MILESTONE_COUNT = 4; private final StringRedisTemplate redisTemplate; private final SimpMessagingTemplate messagingTemplate; @@ -82,6 +88,12 @@ public void evaluate(Long sessionId, Long userId, double distanceTotal) { redisTemplate.expire(closedKey(sessionId), TTL); } } + + // 코스 모드(마일스톤 4개)일 때만 정상(=코스 절반 지점) 알림 평가. + // 마지막 마일스톤(4/4)이 곧 코스 distance 이므로 그걸 courseDistance 로 사용. + if (milestones.size() == COURSE_MILESTONE_COUNT) { + evaluateSummit(sessionId, userId, distanceTotal, milestones.get(milestones.size() - 1)); + } } private void sendOpen(Long sessionId, Long userId, int idx, double mi) { @@ -116,6 +128,57 @@ private void sendClosed(Long sessionId, int idx, double mi) { log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); } + /** + * 코스 거리 50% 지점 도달 시 1회 정상 알림을 발송한다. + * - 자유 기록(session.course == null) 인 경우 정상 개념이 없으니 호출자에서 courseDistance=0 으로 넘기면 skip. + * - Redis SADD 의 반환값으로 idempotent — 두 인스턴스가 동시 호출해도 한 인스턴스만 발송. + * - WebSocket /topic/tracking/{sessionId}/summit + FCM 둘 다 발송. + */ + public void evaluateSummit(Long sessionId, Long userId, double distanceTotal, double courseDistance) { + if (courseDistance <= 0) { + return; + } + double halfwayMark = courseDistance / 2.0; + if (distanceTotal < halfwayMark) { + return; + } + String key = summitNotifiedKey(sessionId); + Long added = redisTemplate.opsForSet().add(key, "1"); + redisTemplate.expire(key, TTL); + if (added == null || added == 0L) { + // 이미 다른 호출에서 보낸 상태 — silent skip + return; + } + sendSummit(sessionId, userId, halfwayMark); + } + + private void sendSummit(Long sessionId, Long userId, double halfwayMark) { + Map payload = new LinkedHashMap<>(); + payload.put("halfwayMark", halfwayMark); + payload.put("reachedAt", LocalDateTime.now().toString()); + messagingTemplate.convertAndSend(summitTopic(sessionId), payload); + log.info("Summit reached: sessionId={} userId={} halfwayMark={}m", sessionId, userId, (int) Math.round(halfwayMark)); + + try { + notificationService.send( + userId, + NotificationType.TRACKING_SUMMIT_REACHED, + Map.of() + ); + } catch (Exception e) { + // FCM 실패가 WebSocket summit 자체를 막아선 안 됨 — 로그만 남기고 진행 + log.warn("Failed to send TRACKING_SUMMIT_REACHED FCM: sessionId={} err={}", sessionId, e.getMessage()); + } + } + + private static String summitNotifiedKey(Long sessionId) { + return "tracking:session:" + sessionId + ":summit:notified"; + } + + private static String summitTopic(Long sessionId) { + return "/topic/tracking/" + sessionId + "/summit"; + } + private List loadMilestones(Long sessionId) { String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId)); if (raw == null || raw.isBlank()) { diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index b61efa88..39a2bc01 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -43,7 +43,7 @@ public class TrackingSessionService { private final TrackingSessionStatsService statsService; private final HikingRecordRepository hikingRecordRepository; private final HikingMemberRepository hikingMemberRepository; - private final TrackingPhotoTriggerService photoTriggerService; + private final TrackingMilestoneTriggerService milestoneTriggerService; private final ApplicationEventPublisher eventPublisher; /** @@ -72,7 +72,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest // 세션 생성과 동시에 사진 마일스톤 거리 리스트(코스 4컷 / 자유 6컷)를 Redis 에 적재한다. // 이후 GPS Consumer 가 매 점마다 evaluate 호출 시 이 키를 읽어 마일스톤 도달을 판정하므로, // 여기서 빠지면 모든 photo-window OPEN/CLOSED 와 FCM 알림이 영영 발송되지 않는다. - photoTriggerService.initializeMilestones(saved); + milestoneTriggerService.initializeMilestones(saved); return TrackingSessionResponse.from(saved); } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java index 7052dd39..013cda98 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java @@ -49,7 +49,7 @@ public class TrackingStreamConsumer implements StreamListener message) { double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt); activityService.markActive(sessionId); - photoTriggerService.evaluate(sessionId, userId, distanceTotal); + milestoneTriggerService.evaluate(sessionId, userId, distanceTotal); Queue queue = buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>()); diff --git a/src/main/resources/db/migration/V17__update_notifications_type_check.sql b/src/main/resources/db/migration/V17__update_notifications_type_check.sql new file mode 100644 index 00000000..2e022064 --- /dev/null +++ b/src/main/resources/db/migration/V17__update_notifications_type_check.sql @@ -0,0 +1,17 @@ +-- ===================================================================== +-- V17__update_notifications_type_check.sql +-- 목적: notifications.type CHECK 제약에 TRACKING_SUMMIT_REACHED 값을 포함시킨다. +-- 배경: Hibernate 가 enum 컬럼 CHECK 를 자동 생성하지만 새 enum 추가 시 자동 갱신하지 않음. +-- 기존 enum 값(COMMUNITY_COMMENT, TRACKING_PHOTO_MILESTONE) + 신규(TRACKING_SUMMIT_REACHED) +-- 으로 제약을 재정의해 INSERT 실패를 막는다. +-- 멱등: DROP IF EXISTS → ADD 패턴. +-- ===================================================================== + +ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check; + +ALTER TABLE notifications ADD CONSTRAINT notifications_type_check + CHECK (type IN ( + 'COMMUNITY_COMMENT', + 'TRACKING_PHOTO_MILESTONE', + 'TRACKING_SUMMIT_REACHED' + )); From 58b56e053e7b7759fb31f78ff2152168b9065d81 Mon Sep 17 00:00:00 2001 From: JangInho Date: Mon, 25 May 2026 12:01:30 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20summit=20notified=20=ED=82=A4?= =?UTF-8?q?=20TTL=20=EA=B0=B1=EC=8B=A0=EC=9D=84=20=EC=B2=AB=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 50% 통과 후 매 GPS 점마다 EXPIRE 가 호출되던 핫패스 부하 제거. SADD 가 새로 추가된 경우(added=1) 에만 TTL 을 세팅하도록 가드 위치 이동. --- .../tracking/service/TrackingMilestoneTriggerService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java index 36f704a3..c9beaf14 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java @@ -144,11 +144,12 @@ public void evaluateSummit(Long sessionId, Long userId, double distanceTotal, do } String key = summitNotifiedKey(sessionId); Long added = redisTemplate.opsForSet().add(key, "1"); - redisTemplate.expire(key, TTL); if (added == null || added == 0L) { - // 이미 다른 호출에서 보낸 상태 — silent skip + // 이미 다른 호출에서 보낸 상태 — silent skip. + // EXPIRE 도 함께 skip 해야 50% 통과 후 매 GPS 점마다 TTL 이 리셋되는 핫패스 부하를 막을 수 있다. return; } + redisTemplate.expire(key, TTL); sendSummit(sessionId, userId, halfwayMark); }