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
2 changes: 2 additions & 0 deletions src/main/java/com/semosan/api/common/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "코스를 찾을 수 없습니다."),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<TrackingPhotoResponse>> 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<ApiResponse<List<TrackingPhotoResponse>>> list(
@AuthenticationPrincipal Long userId,
@PathVariable Long sessionId
) {
List<TrackingPhotoResponse> response = trackingPhotoService.listBySession(userId, sessionId);
return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_LIST_SUCCESS, response);
}
}
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<TrackingPhotoResponse>> 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<ApiResponse<List<TrackingPhotoResponse>>> list(
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
@Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId
);
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<TrackingPhoto, Long> {

List<TrackingPhoto> findByTrackingSession_IdOrderByMilestoneIndexAsc(Long trackingSessionId);

boolean existsByTrackingSession_IdAndMilestoneIndex(Long trackingSessionId, Integer milestoneIndex);
}
Original file line number Diff line number Diff line change
@@ -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<Double> calculate(TrackingSession session) {
if (Boolean.TRUE.equals(session.getIsFreeRecording()) || session.getCourse() == null) {
return freeRecordingMilestones();
}
return courseMilestones(session.getCourse());
}

private List<Double> courseMilestones(Course course) {
double totalMeters = (course.getDistance() == null ? 0.0 : course.getDistance()) * KM_TO_M;
List<Double> 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<Double> freeRecordingMilestones() {
List<Double> 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;
}
}
Loading