Skip to content

Commit a147199

Browse files
authored
Merge pull request #110 from SEMOSAN/feat/#89-summit-push
feat: 코스 절반 지점 도달 시 정상 인증 푸시 추가
2 parents 3725e17 + 58b56e0 commit a147199

5 files changed

Lines changed: 102 additions & 10 deletions

File tree

src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ public enum NotificationType {
2323
"",
2424
"{distance}m 돌파! 인증 사진을 남겨보세요!",
2525
Set.of("distance")
26+
),
27+
28+
/**
29+
* 코스 거리 50% 도달 시 정상 인증 유도.
30+
* 진짜 정상 좌표가 식별 불가해 코스 절반 지점을 "정상" 근처로 간주하는 임시 정책.
31+
* iOS 잠금화면에 title 은 앱 이름(SEMOSAN)이 자동 표시되므로 본 title 은 빈 문자열.
32+
*/
33+
TRACKING_SUMMIT_REACHED(
34+
"",
35+
"정상에 도착했나요? 정상 인증하기!",
36+
Set.of()
2637
);
2738

2839
private final String titleTemplate;

src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java renamed to src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneTriggerService.java

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@
1919
import java.util.stream.Collectors;
2020

2121
/**
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 보장.
2731
*
2832
* TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성.
2933
* Lua 스크립트 또는 분산 락으로 보강 필요.
@@ -32,10 +36,12 @@
3236
@Slf4j
3337
@Service
3438
@RequiredArgsConstructor
35-
public class TrackingPhotoTriggerService {
39+
public class TrackingMilestoneTriggerService {
3640

3741
private static final double TOLERANCE_RATIO = 0.10;
3842
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;
3945

4046
private final StringRedisTemplate redisTemplate;
4147
private final SimpMessagingTemplate messagingTemplate;
@@ -82,6 +88,12 @@ public void evaluate(Long sessionId, Long userId, double distanceTotal) {
8288
redisTemplate.expire(closedKey(sessionId), TTL);
8389
}
8490
}
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+
}
8597
}
8698

8799
private void sendOpen(Long sessionId, Long userId, int idx, double mi) {
@@ -116,6 +128,58 @@ private void sendClosed(Long sessionId, int idx, double mi) {
116128
log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi));
117129
}
118130

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+
119183
private List<Double> loadMilestones(Long sessionId) {
120184
String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId));
121185
if (raw == null || raw.isBlank()) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public class TrackingSessionService {
4343
private final TrackingSessionStatsService statsService;
4444
private final HikingRecordRepository hikingRecordRepository;
4545
private final HikingMemberRepository hikingMemberRepository;
46-
private final TrackingPhotoTriggerService photoTriggerService;
46+
private final TrackingMilestoneTriggerService milestoneTriggerService;
4747
private final ApplicationEventPublisher eventPublisher;
4848

4949
/**
@@ -72,7 +72,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest
7272
// 세션 생성과 동시에 사진 마일스톤 거리 리스트(코스 4컷 / 자유 6컷)를 Redis 에 적재한다.
7373
// 이후 GPS Consumer 가 매 점마다 evaluate 호출 시 이 키를 읽어 마일스톤 도달을 판정하므로,
7474
// 여기서 빠지면 모든 photo-window OPEN/CLOSED 와 FCM 알림이 영영 발송되지 않는다.
75-
photoTriggerService.initializeMilestones(saved);
75+
milestoneTriggerService.initializeMilestones(saved);
7676

7777
return TrackingSessionResponse.from(saved);
7878
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public class TrackingStreamConsumer implements StreamListener<String, MapRecord<
4949

5050
private final TrackingSessionStatsService statsService;
5151
private final TrackingPointFlushService flushService;
52-
private final TrackingPhotoTriggerService photoTriggerService;
52+
private final TrackingMilestoneTriggerService milestoneTriggerService;
5353
private final TrackingSessionActivityService activityService;
5454

5555
@Override
@@ -65,7 +65,7 @@ public void onMessage(MapRecord<String, String, String> message) {
6565

6666
double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
6767
activityService.markActive(sessionId);
68-
photoTriggerService.evaluate(sessionId, userId, distanceTotal);
68+
milestoneTriggerService.evaluate(sessionId, userId, distanceTotal);
6969

7070
Queue<TrackingPointFlushService.PendingPoint> queue =
7171
buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>());
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- =====================================================================
2+
-- V17__update_notifications_type_check.sql
3+
-- 목적: notifications.type CHECK 제약에 TRACKING_SUMMIT_REACHED 값을 포함시킨다.
4+
-- 배경: Hibernate 가 enum 컬럼 CHECK 를 자동 생성하지만 새 enum 추가 시 자동 갱신하지 않음.
5+
-- 기존 enum 값(COMMUNITY_COMMENT, TRACKING_PHOTO_MILESTONE) + 신규(TRACKING_SUMMIT_REACHED)
6+
-- 으로 제약을 재정의해 INSERT 실패를 막는다.
7+
-- 멱등: DROP IF EXISTS → ADD 패턴.
8+
-- =====================================================================
9+
10+
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check;
11+
12+
ALTER TABLE notifications ADD CONSTRAINT notifications_type_check
13+
CHECK (type IN (
14+
'COMMUNITY_COMMENT',
15+
'TRACKING_PHOTO_MILESTONE',
16+
'TRACKING_SUMMIT_REACHED'
17+
));

0 commit comments

Comments
 (0)