diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index d4e6bbc0..f08fe228 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -114,6 +114,8 @@ public enum ErrorStatus implements BaseStatus { TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."), TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."), TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."), + TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."), + TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."), COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."), /** diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 6034906c..723136fb 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -62,6 +62,8 @@ public enum SuccessStatus implements BaseStatus { TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."), TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."), TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."), + TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."), + TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."), /** * Image diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 58d80a0d..41e43e46 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -13,6 +13,16 @@ public enum NotificationType { "새 댓글이 달렸어요", "{actorName}: {commentPreview}", Set.of("actorName", "commentPreview") + ), + + /** + * 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도. + * iOS 잠금화면에 title 은 앱 이름(SEMOSAN)이 자동 표시되므로 본 title 은 빈 문자열. + */ + TRACKING_PHOTO_MILESTONE( + "", + "{distance}m 돌파! 인증 사진을 남겨보세요!", + Set.of("distance") ); private final String titleTemplate; diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java new file mode 100644 index 00000000..f0e1738b --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/TrackingPhotoController.java @@ -0,0 +1,44 @@ +package com.semosan.api.domain.tracking.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.tracking.controller.docs.TrackingPhotoControllerDocs; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import com.semosan.api.domain.tracking.service.TrackingPhotoService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/tracking/sessions/{sessionId}/photos") +@RequiredArgsConstructor +public class TrackingPhotoController implements TrackingPhotoControllerDocs { + + private final TrackingPhotoService trackingPhotoService; + + @PostMapping + @Override + public ResponseEntity> upload( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId, + @Valid @RequestBody TrackingPhotoUploadRequest request + ) { + TrackingPhotoResponse response = trackingPhotoService.upload(userId, sessionId, request); + return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_UPLOAD_SUCCESS, response); + } + + @GetMapping + @Override + public ResponseEntity>> list( + @AuthenticationPrincipal Long userId, + @PathVariable Long sessionId + ) { + List response = trackingPhotoService.listBySession(userId, sessionId); + return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_LIST_SUCCESS, response); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java new file mode 100644 index 00000000..82143142 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/controller/docs/TrackingPhotoControllerDocs.java @@ -0,0 +1,51 @@ +package com.semosan.api.domain.tracking.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "Tracking Photo", description = "트래킹 마일스톤 사진 메타 API") +public interface TrackingPhotoControllerDocs { + + @Operation( + summary = "트래킹 마일스톤 사진 메타 업로드", + description = "클라이언트가 MinIO 등 외부 스토리지(#41) 에 사진을 업로드한 직후 호출. " + + "본 API 는 이미지 바이너리를 받지 않으며 메타(URL, 좌표, 마일스톤 정보) 만 저장한다. " + + "해당 마일스톤에 이미 사진이 있으면 409, 종료된 세션이면 409." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "사진 메타 저장 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 세션 아님", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "세션 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", + description = "중복 마일스톤 또는 종료된 세션", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + ResponseEntity> upload( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId, + @Valid @RequestBody TrackingPhotoUploadRequest request + ); + + @Operation(summary = "트래킹 세션의 사진 목록 조회", + description = "본인 소유 세션의 마일스톤 사진을 milestone_index 오름차순으로 반환합니다.") + ResponseEntity>> list( + @Parameter(hidden = true) @AuthenticationPrincipal Long userId, + @Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId + ); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java b/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java new file mode 100644 index 00000000..d035a553 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/request/TrackingPhotoUploadRequest.java @@ -0,0 +1,35 @@ +package com.semosan.api.domain.tracking.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; + +import java.time.LocalDateTime; + +/** + * 클라이언트가 MinIO 에 사진을 업로드한 직후, 백엔드에 메타를 통보하는 요청. + * imageUrl 자체는 #41 presigned URL 흐름에서 얻는다. + */ +public record TrackingPhotoUploadRequest( + @NotNull(message = "마일스톤 인덱스는 필수입니다.") + @PositiveOrZero(message = "마일스톤 인덱스는 0 이상이어야 합니다.") + Integer milestoneIndex, + + @NotNull(message = "마일스톤 기준 거리(m)는 필수입니다.") + Double milestoneDistanceM, + + @NotBlank(message = "imageUrl 은 필수입니다.") + String imageUrl, + + @NotNull(message = "촬영 시각은 필수입니다.") + LocalDateTime capturedAt, + + @NotNull(message = "위도는 필수입니다.") + Double lat, + + @NotNull(message = "경도는 필수입니다.") + Double lng, + + Double altitude +) { +} diff --git a/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java new file mode 100644 index 00000000..7e502752 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/dto/response/TrackingPhotoResponse.java @@ -0,0 +1,32 @@ +package com.semosan.api.domain.tracking.dto.response; + +import com.semosan.api.domain.tracking.entity.TrackingPhoto; + +import java.time.LocalDateTime; + +public record TrackingPhotoResponse( + Long photoId, + Long trackingSessionId, + Integer milestoneIndex, + Double milestoneDistanceM, + String imageUrl, + LocalDateTime capturedAt, + Double lat, + Double lng, + Double altitude +) { + + public static TrackingPhotoResponse from(TrackingPhoto photo) { + return new TrackingPhotoResponse( + photo.getId(), + photo.getTrackingSession().getId(), + photo.getMilestoneIndex(), + photo.getMilestoneDistanceM(), + photo.getImageUrl(), + photo.getCapturedAt(), + photo.getLat(), + photo.getLng(), + photo.getAltitude() + ); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java new file mode 100644 index 00000000..428471fa --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/entity/TrackingPhoto.java @@ -0,0 +1,87 @@ +package com.semosan.api.domain.tracking.entity; + +import com.semosan.api.common.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 트래킹 진행 중 마일스톤 도달 시 사용자가 촬영해 업로드한 사진의 메타. + * 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장. + * 한 트래킹 세션이 N 개의 사진(=마일스톤 수)을 가질 수 있다 — N 의 상한은 정책상 6. + */ +@Table( + name = "tracking_photos", + indexes = { + @Index(name = "idx_tracking_photos_session", columnList = "tracking_session_id"), + @Index(name = "idx_tracking_photos_session_milestone", + columnList = "tracking_session_id, milestone_index") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackingPhoto extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tracking_session_id", nullable = false) + private TrackingSession trackingSession; + + /** 이 사진이 어떤 마일스톤에 해당하는지 (0-based, 코스 4컷 / 자유 최대 6컷). */ + @Column(name = "milestone_index", nullable = false) + private Integer milestoneIndex; + + /** 마일스톤의 기준 거리 (m). 예: 500, 1000, ... 또는 코스의 distance/4 등. */ + @Column(name = "milestone_distance_m", nullable = false) + private Double milestoneDistanceM; + + /** MinIO 등 외부 스토리지에 업로드된 이미지 URL (presigned URL 발급은 #41). */ + @Column(name = "image_url", nullable = false, length = 500) + private String imageUrl; + + @Column(name = "captured_at", nullable = false) + private LocalDateTime capturedAt; + + @Column(name = "lat", nullable = false) + private Double lat; + + @Column(name = "lng", nullable = false) + private Double lng; + + /** 촬영 시점 디바이스가 보고한 고도 (nullable). */ + @Column(name = "altitude") + private Double altitude; + + public static TrackingPhoto create( + TrackingSession session, + Integer milestoneIndex, + Double milestoneDistanceM, + String imageUrl, + LocalDateTime capturedAt, + Double lat, + Double lng, + Double altitude + ) { + return TrackingPhoto.builder() + .trackingSession(session) + .milestoneIndex(milestoneIndex) + .milestoneDistanceM(milestoneDistanceM) + .imageUrl(imageUrl) + .capturedAt(capturedAt) + .lat(lat) + .lng(lng) + .altitude(altitude) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java new file mode 100644 index 00000000..f2587f2c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/repository/TrackingPhotoRepository.java @@ -0,0 +1,13 @@ +package com.semosan.api.domain.tracking.repository; + +import com.semosan.api.domain.tracking.entity.TrackingPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TrackingPhotoRepository extends JpaRepository { + + List findByTrackingSession_IdOrderByMilestoneIndexAsc(Long trackingSessionId); + + boolean existsByTrackingSession_IdAndMilestoneIndex(Long trackingSessionId, Integer milestoneIndex); +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java new file mode 100644 index 00000000..3a7277ff --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingMilestoneCalculator.java @@ -0,0 +1,48 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.domain.mountain.entity.Course; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 트래킹 세션의 사진 촬영 마일스톤 거리 리스트를 계산한다. + * - 코스 따라가기: course.distance(km) → m 변환 후 1/4, 2/4, 3/4, 4/4 (총 4컷) + * - 자유 기록: 500m 간격, 정책상 최대 6컷 (500/1000/1500/2000/2500/3000m) + * + * 모든 반환 단위는 미터(m). distanceTotal(Haversine) 과 단위 일치. + */ +@Component +public class TrackingMilestoneCalculator { + + private static final int COURSE_MILESTONE_COUNT = 4; + private static final double FREE_RECORDING_INTERVAL_METERS = 500.0; + private static final int FREE_RECORDING_MAX_COUNT = 6; + private static final double KM_TO_M = 1000.0; + + public List calculate(TrackingSession session) { + if (Boolean.TRUE.equals(session.getIsFreeRecording()) || session.getCourse() == null) { + return freeRecordingMilestones(); + } + return courseMilestones(session.getCourse()); + } + + private List courseMilestones(Course course) { + double totalMeters = (course.getDistance() == null ? 0.0 : course.getDistance()) * KM_TO_M; + List result = new ArrayList<>(COURSE_MILESTONE_COUNT); + for (int i = 1; i <= COURSE_MILESTONE_COUNT; i++) { + result.add(totalMeters * i / COURSE_MILESTONE_COUNT); + } + return result; + } + + private List freeRecordingMilestones() { + List result = new ArrayList<>(FREE_RECORDING_MAX_COUNT); + for (int i = 1; i <= FREE_RECORDING_MAX_COUNT; i++) { + result.add(FREE_RECORDING_INTERVAL_METERS * i); + } + return result; + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java new file mode 100644 index 00000000..1ebcee30 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoService.java @@ -0,0 +1,69 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest; +import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse; +import com.semosan.api.domain.tracking.entity.TrackingPhoto; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import com.semosan.api.domain.tracking.enums.TrackingSessionStatus; +import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository; +import com.semosan.api.domain.tracking.repository.TrackingSessionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrackingPhotoService { + + private static final Set ACTIVE_STATES = + EnumSet.of(TrackingSessionStatus.IN_PROGRESS, TrackingSessionStatus.PAUSED); + + private final TrackingPhotoRepository trackingPhotoRepository; + private final TrackingSessionRepository trackingSessionRepository; + + @Transactional + public TrackingPhotoResponse upload(Long userId, Long sessionId, TrackingPhotoUploadRequest request) { + TrackingSession session = findOwnedSession(userId, sessionId); + if (!ACTIVE_STATES.contains(session.getStatus())) { + throw new GeneralException(ErrorStatus.TRACKING_PHOTO_SESSION_INACTIVE); + } + if (trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex(sessionId, request.milestoneIndex())) { + throw new GeneralException(ErrorStatus.TRACKING_PHOTO_DUPLICATE); + } + TrackingPhoto photo = TrackingPhoto.create( + session, + request.milestoneIndex(), + request.milestoneDistanceM(), + request.imageUrl(), + request.capturedAt(), + request.lat(), + request.lng(), + request.altitude() + ); + TrackingPhoto saved = trackingPhotoRepository.save(photo); + return TrackingPhotoResponse.from(saved); + } + + public List listBySession(Long userId, Long sessionId) { + findOwnedSession(userId, sessionId); + return trackingPhotoRepository.findByTrackingSession_IdOrderByMilestoneIndexAsc(sessionId).stream() + .map(TrackingPhotoResponse::from) + .toList(); + } + + private TrackingSession findOwnedSession(Long userId, Long sessionId) { + TrackingSession session = trackingSessionRepository.findById(sessionId) + .orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND)); + if (!session.isOwnedBy(userId)) { + throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN); + } + return session; + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java new file mode 100644 index 00000000..7f8f3d8c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingPhotoTriggerService.java @@ -0,0 +1,151 @@ +package com.semosan.api.domain.tracking.service; + +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.NotificationService; +import com.semosan.api.domain.tracking.entity.TrackingSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 트래킹 진행 중 GPS 거리 누적값을 보고, 사전에 결정된 마일스톤 거리 ±10% 윈도우에 + * 진입/이탈할 때 다음 동작을 수행한다. + * - 진입(OPEN): WebSocket OPEN 메시지 + FCM 푸시 ("{distance}m 돌파! 인증 사진을 남겨보세요!") + * - 이탈(CLOSED): WebSocket CLOSED 메시지 (FCM 없음) + * 마일스톤 도달 상태는 Redis Set 으로 idempotent 하게 관리. + * + * TODO: 다중 인스턴스 동시성 — 같은 마일스톤에 대해 두 인스턴스가 동시 OPEN 발송할 가능성. + * Lua 스크립트 또는 분산 락으로 보강 필요. + * TODO: 푸시 본문 단위 — 현재 m 단위 정수. 코스 마일스톤이 정수가 아닐 때 km 단위 포맷 고려. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TrackingPhotoTriggerService { + + private static final double TOLERANCE_RATIO = 0.10; + private static final Duration TTL = Duration.ofHours(24); + + private final StringRedisTemplate redisTemplate; + private final SimpMessagingTemplate messagingTemplate; + private final NotificationService notificationService; + private final TrackingMilestoneCalculator milestoneCalculator; + + /** 세션 생성 시 1회 호출 — 마일스톤 거리 리스트를 Redis 에 저장. */ + public void initializeMilestones(TrackingSession session) { + List milestones = milestoneCalculator.calculate(session); + if (milestones.isEmpty()) { + return; + } + String key = milestonesKey(session.getId()); + String value = milestones.stream().map(String::valueOf).collect(Collectors.joining(",")); + redisTemplate.opsForValue().set(key, value); + redisTemplate.expire(key, TTL); + } + + /** GPS Consumer 가 매 점 처리 직후 호출. distanceTotal 은 누적 거리(m). */ + public void evaluate(Long sessionId, Long userId, double distanceTotal) { + List milestones = loadMilestones(sessionId); + if (milestones.isEmpty()) { + return; + } + Set opened = membersOrEmpty(openedKey(sessionId)); + Set closed = membersOrEmpty(closedKey(sessionId)); + + for (int i = 0; i < milestones.size(); i++) { + String idxStr = String.valueOf(i); + double mi = milestones.get(i); + double entry = mi * (1 - TOLERANCE_RATIO); + double exit = mi * (1 + TOLERANCE_RATIO); + boolean isOpened = opened.contains(idxStr); + boolean isClosed = closed.contains(idxStr); + + if (!isOpened && distanceTotal >= entry && distanceTotal <= exit) { + sendOpen(sessionId, userId, i, mi); + redisTemplate.opsForSet().add(openedKey(sessionId), idxStr); + redisTemplate.expire(openedKey(sessionId), TTL); + } + if (isOpened && !isClosed && distanceTotal > exit) { + sendClosed(sessionId, i, mi); + redisTemplate.opsForSet().add(closedKey(sessionId), idxStr); + redisTemplate.expire(closedKey(sessionId), TTL); + } + } + } + + private void sendOpen(Long sessionId, Long userId, int idx, double mi) { + Map payload = new LinkedHashMap<>(); + payload.put("milestoneIndex", idx); + payload.put("milestoneDistance", mi); + payload.put("status", "OPEN"); + payload.put("openedAt", LocalDateTime.now().toString()); + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); + log.info("Photo window OPEN: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); + + try { + notificationService.send( + userId, + NotificationType.TRACKING_PHOTO_MILESTONE, + Map.of("distance", (int) Math.round(mi)) + ); + } catch (Exception e) { + // FCM 발송 실패가 WebSocket OPEN 자체를 막아선 안 됨 — 로그만 남기고 진행 + log.warn("Failed to send TRACKING_PHOTO_MILESTONE FCM: sessionId={} idx={} mi={} err={}", + sessionId, idx, mi, e.getMessage()); + } + } + + private void sendClosed(Long sessionId, int idx, double mi) { + Map payload = new LinkedHashMap<>(); + payload.put("milestoneIndex", idx); + payload.put("milestoneDistance", mi); + payload.put("status", "CLOSED"); + payload.put("closedAt", LocalDateTime.now().toString()); + messagingTemplate.convertAndSend(photoTopic(sessionId), payload); + log.info("Photo window CLOSED: sessionId={} idx={} milestone={}m", sessionId, idx, (int) Math.round(mi)); + } + + private List loadMilestones(Long sessionId) { + String raw = redisTemplate.opsForValue().get(milestonesKey(sessionId)); + if (raw == null || raw.isBlank()) { + return List.of(); + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Double::parseDouble) + .toList(); + } + + private Set membersOrEmpty(String key) { + Set members = redisTemplate.opsForSet().members(key); + return members == null ? Set.of() : members; + } + + private static String milestonesKey(Long sessionId) { + return "tracking:session:" + sessionId + ":milestones"; + } + + private static String openedKey(Long sessionId) { + return "tracking:session:" + sessionId + ":photo:opened"; + } + + private static String closedKey(Long sessionId) { + return "tracking:session:" + sessionId + ":photo:closed"; + } + + private static String photoTopic(Long sessionId) { + return "/topic/tracking/" + sessionId + "/photo-window"; + } +} diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java index bdfdc2f7..513c0ec3 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionService.java @@ -39,8 +39,18 @@ public class TrackingSessionService { private final TrackingSessionStatsService statsService; private final HikingRecordRepository hikingRecordRepository; private final HikingMemberRepository hikingMemberRepository; + private final TrackingPhotoTriggerService photoTriggerService; private final ApplicationEventPublisher eventPublisher; + /** + * 세션 생성. 유저당 진행 중 세션은 1개만 허용한다. + * + * TODO/주의 — 사용자가 종료(complete/abandon)를 누르지 않고 앱을 강제 종료하면 이전 세션이 IN_PROGRESS + * 로 유령처럼 남아 다음 세션 생성을 막을 수 있다. 대응 메커니즘: + * 1) 클라이언트 진입 시 GET /me/active 로 진행 중 세션 확인 후 "이어서 / 폐기 후 새로 시작" 분기 노출 + * 2) {@link TrackingSessionExpiryScheduler} 가 24h 무업데이트 세션을 ABANDONED 처리하는 fallback + * 운영 시 자주 발생할 수 있는 케이스이므로 클라이언트 흐름에서 (1)을 반드시 구현할 것. + */ @Transactional public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest request) { if (trackingSessionRepository.existsByUser_IdAndStatusIn(userId, TrackingSessionStatus.ACTIVE_STATES)) { @@ -54,6 +64,7 @@ public TrackingSessionResponse create(Long userId, CreateTrackingSessionRequest TrackingSession session = TrackingSession.create(user, mountain, course, request.isFreeRecording()); TrackingSession saved = trackingSessionRepository.save(session); + photoTriggerService.initializeMilestones(saved); return TrackingSessionResponse.from(saved); } diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java index 75a33d81..4291a2c9 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingSessionStatsService.java @@ -43,7 +43,8 @@ public class TrackingSessionStatsService { private final StringRedisTemplate redisTemplate; - public void recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) { + /** 점을 누적 갱신하고, 갱신 후 총 거리(m)를 반환한다 — 호출자에서 마일스톤 트리거에 활용. */ + public double recordPoint(Long sessionId, double lat, double lng, Double altitude, LocalDateTime recordedAt) { String key = statsKey(sessionId); HashOperations hash = redisTemplate.opsForHash(); Map prev = hash.entries(key); @@ -90,6 +91,7 @@ public void recordPoint(Long sessionId, double lat, double lng, Double altitude, hash.putAll(key, next); redisTemplate.expire(key, TTL); + return distanceTotal; } public static String statsKey(Long sessionId) { diff --git a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java index 1103962e..7052dd39 100644 --- a/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java +++ b/src/main/java/com/semosan/api/domain/tracking/service/TrackingStreamConsumer.java @@ -39,6 +39,7 @@ public class TrackingStreamConsumer implements StreamListener message) { Map body = message.getValue(); try { Long sessionId = Long.parseLong(body.get(F_SESSION_ID)); + Long userId = Long.parseLong(body.get(F_USER_ID)); double lat = Double.parseDouble(body.get(F_LAT)); double lng = Double.parseDouble(body.get(F_LNG)); Double altitude = parseNullableDouble(body.get(F_ALTITUDE)); LocalDateTime recordedAt = LocalDateTime.parse(body.get(F_RECORDED_AT)); - statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt); + double distanceTotal = statsService.recordPoint(sessionId, lat, lng, altitude, recordedAt); activityService.markActive(sessionId); + photoTriggerService.evaluate(sessionId, userId, distanceTotal); Queue queue = buffers.computeIfAbsent(sessionId, k -> new ConcurrentLinkedQueue<>()); diff --git a/src/main/resources/db/migration/V10__create_tracking_photos.sql b/src/main/resources/db/migration/V10__create_tracking_photos.sql new file mode 100644 index 00000000..c2ec8635 --- /dev/null +++ b/src/main/resources/db/migration/V10__create_tracking_photos.sql @@ -0,0 +1,40 @@ +-- ===================================================================== +-- V10__create_tracking_photos.sql +-- 목적: 트래킹 마일스톤 사진 메타 저장 테이블 생성. +-- 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장. +-- + notifications.type CHECK 제약 갱신 (TRACKING_PHOTO_MILESTONE 추가) +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS tracking_photos +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + tracking_session_id bigint not null + constraint fk_tracking_photos_session + references tracking_sessions, + milestone_index integer not null, + milestone_distance_m double precision not null, + image_url varchar(500) not null, + captured_at timestamp(6) not null, + lat double precision not null, + lng double precision not null, + altitude double precision +); + +CREATE INDEX IF NOT EXISTS idx_tracking_photos_session + ON tracking_photos (tracking_session_id); + +CREATE INDEX IF NOT EXISTS idx_tracking_photos_session_milestone + ON tracking_photos (tracking_session_id, milestone_index); + +-- ===================================================================== +-- notifications.type CHECK 제약 갱신. +-- Hibernate 가 enum 컬럼 생성 시점의 값만으로 CHECK 를 만들고 +-- 새 enum 값 추가 시 자동 갱신하지 않으므로 수동 갱신 필요. +-- 새 enum 값 추가 시 동일 패턴으로 갱신. +-- ===================================================================== +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'));