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 62% 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..c9beaf14 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,58 @@ 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"); + if (added == null || added == 0L) { + // 이미 다른 호출에서 보낸 상태 — silent skip. + // EXPIRE 도 함께 skip 해야 50% 통과 후 매 GPS 점마다 TTL 이 리셋되는 핫패스 부하를 막을 수 있다. + return; + } + redisTemplate.expire(key, TTL); + 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' + ));