Skip to content

Commit 76ce8bb

Browse files
committed
feat: 거리 마일스톤 도달 시 사진 촬영 푸시 트리거
1 parent bc6117d commit 76ce8bb

5 files changed

Lines changed: 218 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.semosan.api.domain.tracking.service;
2+
3+
import com.semosan.api.domain.mountain.entity.Course;
4+
import com.semosan.api.domain.tracking.entity.TrackingSession;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
10+
/**
11+
* 트래킹 세션의 사진 촬영 마일스톤 거리 리스트를 계산한다.
12+
* - 코스 따라가기: course.distance(km) → m 변환 후 1/4, 2/4, 3/4, 4/4 (총 4컷)
13+
* - 자유 기록: 500m 간격, 정책상 최대 6컷 (500/1000/1500/2000/2500/3000m)
14+
*
15+
* 모든 반환 단위는 미터(m). distanceTotal(Haversine) 과 단위 일치.
16+
*/
17+
@Component
18+
public class TrackingMilestoneCalculator {
19+
20+
private static final int COURSE_MILESTONE_COUNT = 4;
21+
private static final double FREE_RECORDING_INTERVAL_METERS = 500.0;
22+
private static final int FREE_RECORDING_MAX_COUNT = 6;
23+
private static final double KM_TO_M = 1000.0;
24+
25+
public List<Double> calculate(TrackingSession session) {
26+
if (Boolean.TRUE.equals(session.getIsFreeRecording()) || session.getCourse() == null) {
27+
return freeRecordingMilestones();
28+
}
29+
return courseMilestones(session.getCourse());
30+
}
31+
32+
private List<Double> courseMilestones(Course course) {
33+
double totalMeters = (course.getDistance() == null ? 0.0 : course.getDistance()) * KM_TO_M;
34+
List<Double> result = new ArrayList<>(COURSE_MILESTONE_COUNT);
35+
for (int i = 1; i <= COURSE_MILESTONE_COUNT; i++) {
36+
result.add(totalMeters * i / COURSE_MILESTONE_COUNT);
37+
}
38+
return result;
39+
}
40+
41+
private List<Double> freeRecordingMilestones() {
42+
List<Double> result = new ArrayList<>(FREE_RECORDING_MAX_COUNT);
43+
for (int i = 1; i <= FREE_RECORDING_MAX_COUNT; i++) {
44+
result.add(FREE_RECORDING_INTERVAL_METERS * i);
45+
}
46+
return result;
47+
}
48+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
}

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ public class TrackingSessionService {
4040
private final TrackingSessionStatsService statsService;
4141
private final HikingRecordRepository hikingRecordRepository;
4242
private final HikingMemberRepository hikingMemberRepository;
43+
private final TrackingPhotoTriggerService photoTriggerService;
4344

45+
/**
46+
* 세션 생성. 유저당 진행 중 세션은 1개만 허용한다.
47+
*
48+
* TODO/주의 — 사용자가 종료(complete/abandon)를 누르지 않고 앱을 강제 종료하면 이전 세션이 IN_PROGRESS
49+
* 로 유령처럼 남아 다음 세션 생성을 막을 수 있다. 대응 메커니즘:
50+
* 1) 클라이언트 진입 시 GET /me/active 로 진행 중 세션 확인 후 "이어서 / 폐기 후 새로 시작" 분기 노출
51+
* 2) {@link TrackingSessionExpiryScheduler} 가 24h 무업데이트 세션을 ABANDONED 처리하는 fallback
52+
* 운영 시 자주 발생할 수 있는 케이스이므로 클라이언트 흐름에서 (1)을 반드시 구현할 것.
53+
*/
4454
@Transactional
4555
public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) {
4656
if (trackingSessionRepository.existsByUser_IdAndStatusIn(userId, ACTIVE_STATES)) {
@@ -54,6 +64,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest
5464

5565
TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording());
5666
TrackingSession saved = trackingSessionRepository.save(session);
67+
photoTriggerService.initializeMilestones(saved);
5768
return TrackingSessionResponse.from(saved);
5869
}
5970

src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ public class TrackingSessionStatsService {
4343

4444
private final StringRedisTemplate redisTemplate;
4545

46-
public void recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) {
46+
/** 점을 누적 갱신하고, 갱신 후 총 거리(m)를 반환한다 — 호출자에서 마일스톤 트리거에 활용. */
47+
public double recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) {
4748
String key = statsKey(sessionId);
4849
HashOperations<String, String, String> hash = redisTemplate.opsForHash();
4950
Map<String, String> prev = hash.entries(key);
@@ -90,6 +91,7 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude,
9091

9192
hash.putAll(key, next);
9293
redisTemplate.expire(key, TTL);
94+
return distanceTotal;
9395
}
9496

9597
public static String statsKey(Long sessionId) {

src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public class TrackingStreamConsumer implements StreamListener<String, MapRecord<
3636
private static final int FLUSH_THRESHOLD = 100;
3737

3838
private static final String F_SESSION_ID = "sessionId";
39+
private static final String F_USER_ID = "userId";
3940
private static final String F_LAT = "lat";
4041
private static final String F_LNG = "lng";
4142
private static final String F_ALTITUDE = "altitude";
@@ -45,18 +46,21 @@ public class TrackingStreamConsumer implements StreamListener<String, MapRecord<
4546

4647
private final TrackingSessionStatsService statsService;
4748
private final TrackingPointFlushService flushService;
49+
private final TrackingPhotoTriggerService photoTriggerService;
4850

4951
@Override
5052
public void onMessage(MapRecord<String, String, String> message) {
5153
Map<String, String> body = message.getValue();
5254
try {
5355
Long sessionId = Long.parseLong(body.get(F_SESSION_ID));
56+
Long userId = Long.parseLong(body.get(F_USER_ID));
5457
double lat = Double.parseDouble(body.get(F_LAT));
5558
double lng = Double.parseDouble(body.get(F_LNG));
5659
Double altitude = parseNullableDouble(body.get(F_ALTITUDE));
5760
LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT));
5861

59-
statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
62+
double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
63+
photoTriggerService.evaluate(sessionId, userId, distanceTotal);
6064

6165
Queue<TrackingPointFlushService.PendingPoint> queue =
6266
buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>());

0 commit comments

Comments
 (0)