|
| 1 | +package com.semosan.api.domain.tracking.service; |
| 2 | + |
| 3 | +import com.semosan.api.domain.notification.enums.NotificationType; |
| 4 | +import com.semosan.api.domain.notification.service.NotificationService; |
| 5 | +import com.semosan.api.domain.tracking.entity.TrackingSession; |
| 6 | +import lombok.RequiredArgsConstructor; |
| 7 | +import lombok.extern.slf4j.Slf4j; |
| 8 | +import org.springframework.data.redis.core.StringRedisTemplate; |
| 9 | +import org.springframework.messaging.simp.SimpMessagingTemplate; |
| 10 | +import org.springframework.stereotype.Service; |
| 11 | + |
| 12 | +import java.time.Duration; |
| 13 | +import java.time.LocalDateTime; |
| 14 | +import java.util.Arrays; |
| 15 | +import java.util.LinkedHashMap; |
| 16 | +import java.util.List; |
| 17 | +import java.util.Map; |
| 18 | +import java.util.Set; |
| 19 | +import java.util.stream.Collectors; |
| 20 | + |
| 21 | +/** |
| 22 | + * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리 ±10% 윈도우에 |
| 23 | + * 진입/이탈할 때 다음 동작을 수행한다. |
| 24 | + * - 진입(OPEN): WebSocket OPEN 메시지 + FCM 푸시 ("{distance}m 돌파! 인증 사진을 남겨보세요!") |
| 25 | + * - 이탈(CLOSED): WebSocket CLOSED 메시지 (FCM 없음) |
| 26 | + * 마일스톤 도달 상태는 Redis Set 으로 idempotent 하게 관리. |
| 27 | + * |
| 28 | + * TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성. |
| 29 | + * Lua 스크립트 또는 분산 락으로 보강 필요. |
| 30 | + * TODO: 푸시 본문 단위 — 현재 m 단위 정수. 코스 마일스톤이 정수가 아닐 때 km 단위 포맷 고려. |
| 31 | + */ |
| 32 | +@Slf4j |
| 33 | +@Service |
| 34 | +@RequiredArgsConstructor |
| 35 | +public class TrackingPhotoTriggerService { |
| 36 | + |
| 37 | + private static final double TOLERANCE_RATIO = 0.10; |
| 38 | + private static final Duration TTL = Duration.ofHours(24); |
| 39 | + |
| 40 | + private final StringRedisTemplate redisTemplate; |
| 41 | + private final SimpMessagingTemplate messagingTemplate; |
| 42 | + private final NotificationService notificationService; |
| 43 | + private final TrackingMilestoneCalculator milestoneCalculator; |
| 44 | + |
| 45 | + /** 세션 생성 시 1회 호출 — 마일스톤 거리 리스트를 Redis 에 저장. */ |
| 46 | + public void initializeMilestones(TrackingSession session) { |
| 47 | + List<Double> milestones = milestoneCalculator.calculate(session); |
| 48 | + if (milestones.isEmpty()) { |
| 49 | + return; |
| 50 | + } |
| 51 | + String key = milestonesKey(session.getId()); |
| 52 | + String value = milestones.stream().map(String::valueOf).collect(Collectors.joining(",")); |
| 53 | + redisTemplate.opsForValue().set(key, value); |
| 54 | + redisTemplate.expire(key, TTL); |
| 55 | + } |
| 56 | + |
| 57 | + /** GPS Consumer 가 매 점 처리 직후 호출. distanceTotal 은 누적 거리(m). */ |
| 58 | + public void evaluate(Long sessionId, Long userId, double distanceTotal) { |
| 59 | + List<Double> milestones = loadMilestones(sessionId); |
| 60 | + if (milestones.isEmpty()) { |
| 61 | + return; |
| 62 | + } |
| 63 | + Set<String> opened = membersOrEmpty(openedKey(sessionId)); |
| 64 | + Set<String> closed = membersOrEmpty(closedKey(sessionId)); |
| 65 | + |
| 66 | + for (int i = 0; i < milestones.size(); i++) { |
| 67 | + String idxStr = String.valueOf(i); |
| 68 | + double mi = milestones.get(i); |
| 69 | + double entry = mi * (1 - TOLERANCE_RATIO); |
| 70 | + double exit = mi * (1 + TOLERANCE_RATIO); |
| 71 | + boolean isOpened = opened.contains(idxStr); |
| 72 | + boolean isClosed = closed.contains(idxStr); |
| 73 | + |
| 74 | + if (!isOpened && distanceTotal >= entry && distanceTotal <= exit) { |
| 75 | + sendOpen(sessionId, userId, i, mi); |
| 76 | + redisTemplate.opsForSet().add(openedKey(sessionId), idxStr); |
| 77 | + redisTemplate.expire(openedKey(sessionId), TTL); |
| 78 | + } |
| 79 | + if (isOpened && !isClosed && distanceTotal > exit) { |
| 80 | + sendClosed(sessionId, i, mi); |
| 81 | + redisTemplate.opsForSet().add(closedKey(sessionId), idxStr); |
| 82 | + redisTemplate.expire(closedKey(sessionId), TTL); |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + private void sendOpen(Long sessionId, Long userId, int idx, double mi) { |
| 88 | + Map<String, Object> payload = new LinkedHashMap<>(); |
| 89 | + payload.put("milestoneIndex", idx); |
| 90 | + payload.put("milestoneDistance", mi); |
| 91 | + payload.put("status", "OPEN"); |
| 92 | + payload.put("openedAt", LocalDateTime.now().toString()); |
| 93 | + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); |
| 94 | + log.info("Photo window OPEN: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); |
| 95 | + |
| 96 | + try { |
| 97 | + notificationService.send( |
| 98 | + userId, |
| 99 | + NotificationType.TRACKING_PHOTO_MILESTONE, |
| 100 | + Map.of("distance", (int) Math.round(mi)) |
| 101 | + ); |
| 102 | + } catch (Exception e) { |
| 103 | + // FCM 발송 실패가 WebSocket OPEN 자체를 막아선 안 됨 — 로그만 남기고 진행 |
| 104 | + log.warn("Failed to send TRACKING_PHOTO_MILESTONE FCM: sessionId={} idx={} mi={} err={}", |
| 105 | + sessionId, idx, mi, e.getMessage()); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + private void sendClosed(Long sessionId, int idx, double mi) { |
| 110 | + Map<String, Object> payload = new LinkedHashMap<>(); |
| 111 | + payload.put("milestoneIndex", idx); |
| 112 | + payload.put("milestoneDistance", mi); |
| 113 | + payload.put("status", "CLOSED"); |
| 114 | + payload.put("closedAt", LocalDateTime.now().toString()); |
| 115 | + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); |
| 116 | + log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); |
| 117 | + } |
| 118 | + |
| 119 | + private List<Double> loadMilestones(Long sessionId) { |
| 120 | + String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId)); |
| 121 | + if (raw == null || raw.isBlank()) { |
| 122 | + return List.of(); |
| 123 | + } |
| 124 | + return Arrays.stream(raw.split(",")) |
| 125 | + .map(String::trim) |
| 126 | + .filter(s -> !s.isEmpty()) |
| 127 | + .map(Double::parseDouble) |
| 128 | + .toList(); |
| 129 | + } |
| 130 | + |
| 131 | + private Set<String> membersOrEmpty(String key) { |
| 132 | + Set<String> members = redisTemplate.opsForSet().members(key); |
| 133 | + return members == null ? Set.of() : members; |
| 134 | + } |
| 135 | + |
| 136 | + private static String milestonesKey(Long sessionId) { |
| 137 | + return "tracking:session:" + sessionId + ":milestones"; |
| 138 | + } |
| 139 | + |
| 140 | + private static String openedKey(Long sessionId) { |
| 141 | + return "tracking:session:" + sessionId + ":photo:opened"; |
| 142 | + } |
| 143 | + |
| 144 | + private static String closedKey(Long sessionId) { |
| 145 | + return "tracking:session:" + sessionId + ":photo:closed"; |
| 146 | + } |
| 147 | + |
| 148 | + private static String photoTopic(Long sessionId) { |
| 149 | + return "/topic/tracking/" + sessionId + "/photo-window"; |
| 150 | + } |
| 151 | +} |
0 commit comments