|
19 | 19 | import java.util.stream.Collectors; |
20 | 20 |
|
21 | 21 | /** |
22 | | - * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리 ±10% 윈도우에 |
23 | | - * 진입/이탈할 때 다음 동작을 수행한다. |
24 | | - * - 진입(OPEN): WebSocket OPEN 메시지 + FCM 푸시 ("{distance}m 돌파! 인증 사진을 남겨보세요!") |
25 | | - * - 이탈(CLOSED): WebSocket CLOSED 메시지 (FCM 없음) |
26 | | - * 마일스톤 도달 상태는 Redis Set 으로 idempotent 하게 관리. |
| 22 | + * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리에 도달하면 |
| 23 | + * 사용자에게 두 종류의 알림을 트리거한다. |
| 24 | + * |
| 25 | + * 1) 사진 마일스톤(photo) — 코스 4컷 / 자유 6컷. |
| 26 | + * ±10% 윈도우 진입 시 OPEN, 이탈 시 CLOSED. 채널: WebSocket(/topic/.../photo-window) + FCM OPEN 시점만. |
| 27 | + * |
| 28 | + * 2) 정상 도달(summit) — 코스 거리 50% 지점 도달 시 1회 (자유 기록은 skip). |
| 29 | + * 코스 정상 좌표를 정확히 식별 못해 임시 정책으로 "코스 절반" 을 정상 근처로 본다. |
| 30 | + * 채널: WebSocket(/topic/.../summit) + FCM. Redis Set 으로 1회 idempotent 보장. |
27 | 31 | * |
28 | 32 | * TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성. |
29 | 33 | * Lua 스크립트 또는 분산 락으로 보강 필요. |
|
32 | 36 | @Slf4j |
33 | 37 | @Service |
34 | 38 | @RequiredArgsConstructor |
35 | | -public class TrackingPhotoTriggerService { |
| 39 | +public class TrackingMilestoneTriggerService { |
36 | 40 |
|
37 | 41 | private static final double TOLERANCE_RATIO = 0.10; |
38 | 42 | private static final Duration TTL = Duration.ofHours(24); |
| 43 | + /** 코스 모드의 사진 마일스톤 개수 (1/4, 2/4, 3/4, 4/4). TrackingMilestoneCalculator 와 동기 유지. */ |
| 44 | + private static final int COURSE_MILESTONE_COUNT = 4; |
39 | 45 |
|
40 | 46 | private final StringRedisTemplate redisTemplate; |
41 | 47 | private final SimpMessagingTemplate messagingTemplate; |
@@ -82,6 +88,12 @@ public void evaluate(Long sessionId, Long userId, double distanceTotal) { |
82 | 88 | redisTemplate.expire(closedKey(sessionId), TTL); |
83 | 89 | } |
84 | 90 | } |
| 91 | + |
| 92 | + // 코스 모드(마일스톤 4개)일 때만 정상(=코스 절반 지점) 알림 평가. |
| 93 | + // 마지막 마일스톤(4/4)이 곧 코스 distance 이므로 그걸 courseDistance 로 사용. |
| 94 | + if (milestones.size() == COURSE_MILESTONE_COUNT) { |
| 95 | + evaluateSummit(sessionId, userId, distanceTotal, milestones.get(milestones.size() - 1)); |
| 96 | + } |
85 | 97 | } |
86 | 98 |
|
87 | 99 | private void sendOpen(Long sessionId, Long userId, int idx, double mi) { |
@@ -116,6 +128,58 @@ private void sendClosed(Long sessionId, int idx, double mi) { |
116 | 128 | log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); |
117 | 129 | } |
118 | 130 |
|
| 131 | + /** |
| 132 | + * 코스 거리 50% 지점 도달 시 1회 정상 알림을 발송한다. |
| 133 | + * - 자유 기록(session.course == null) 인 경우 정상 개념이 없으니 호출자에서 courseDistance=0 으로 넘기면 skip. |
| 134 | + * - Redis SADD 의 반환값으로 idempotent — 두 인스턴스가 동시 호출해도 한 인스턴스만 발송. |
| 135 | + * - WebSocket /topic/tracking/{sessionId}/summit + FCM 둘 다 발송. |
| 136 | + */ |
| 137 | + public void evaluateSummit(Long sessionId, Long userId, double distanceTotal, double courseDistance) { |
| 138 | + if (courseDistance <= 0) { |
| 139 | + return; |
| 140 | + } |
| 141 | + double halfwayMark = courseDistance / 2.0; |
| 142 | + if (distanceTotal < halfwayMark) { |
| 143 | + return; |
| 144 | + } |
| 145 | + String key = summitNotifiedKey(sessionId); |
| 146 | + Long added = redisTemplate.opsForSet().add(key, "1"); |
| 147 | + if (added == null || added == 0L) { |
| 148 | + // 이미 다른 호출에서 보낸 상태 — silent skip. |
| 149 | + // EXPIRE 도 함께 skip 해야 50% 통과 후 매 GPS 점마다 TTL 이 리셋되는 핫패스 부하를 막을 수 있다. |
| 150 | + return; |
| 151 | + } |
| 152 | + redisTemplate.expire(key, TTL); |
| 153 | + sendSummit(sessionId, userId, halfwayMark); |
| 154 | + } |
| 155 | + |
| 156 | + private void sendSummit(Long sessionId, Long userId, double halfwayMark) { |
| 157 | + Map<String, Object> payload = new LinkedHashMap<>(); |
| 158 | + payload.put("halfwayMark", halfwayMark); |
| 159 | + payload.put("reachedAt", LocalDateTime.now().toString()); |
| 160 | + messagingTemplate.convertAndSend(summitTopic(sessionId), payload); |
| 161 | + log.info("Summit reached: sessionId={} userId={} halfwayMark={}m", sessionId, userId, (int) Math.round(halfwayMark)); |
| 162 | + |
| 163 | + try { |
| 164 | + notificationService.send( |
| 165 | + userId, |
| 166 | + NotificationType.TRACKING_SUMMIT_REACHED, |
| 167 | + Map.of() |
| 168 | + ); |
| 169 | + } catch (Exception e) { |
| 170 | + // FCM 실패가 WebSocket summit 자체를 막아선 안 됨 — 로그만 남기고 진행 |
| 171 | + log.warn("Failed to send TRACKING_SUMMIT_REACHED FCM: sessionId={} err={}", sessionId, e.getMessage()); |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + private static String summitNotifiedKey(Long sessionId) { |
| 176 | + return "tracking:session:" + sessionId + ":summit:notified"; |
| 177 | + } |
| 178 | + |
| 179 | + private static String summitTopic(Long sessionId) { |
| 180 | + return "/topic/tracking/" + sessionId + "/summit"; |
| 181 | + } |
| 182 | + |
119 | 183 | private List<Double> loadMilestones(Long sessionId) { |
120 | 184 | String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId)); |
121 | 185 | if (raw == null || raw.isBlank()) { |
|
0 commit comments