Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 스크립트 또는 분산 락으로 보강 필요.
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 이미 다른 호출에서 보낸 상태 — 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<String, Object> 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<Double> loadMilestones(Long sessionId) {
String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId));
if (raw == null || raw.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public class TrackingStreamConsumer implements StreamListener<String, MapRecord<

private final TrackingSessionStatsService statsService;
private final TrackingPointFlushService flushService;
private final TrackingPhotoTriggerService photoTriggerService;
private final TrackingMilestoneTriggerService milestoneTriggerService;
private final TrackingSessionActivityService activityService;

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

double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt);
activityService.markActive(sessionId);
photoTriggerService.evaluate(sessionId, userId, distanceTotal);
milestoneTriggerService.evaluate(sessionId, userId, distanceTotal);

Queue<TrackingPointFlushService.PendingPoint> queue =
buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>());
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
));