Skip to content

Commit 8899f0d

Browse files
authored
Merge pull request #79 from SEMOSAN/feat/#46-tracking-photo-push
[Merge] #46 트래킹 사진 푸시 머지
2 parents f6b3ae4 + b586ec5 commit 8899f0d

16 files changed

Lines changed: 603 additions & 2 deletions

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ public enum ErrorStatus implements BaseStatus {
114114
TRACKING_SESSION_INVALID_STATE(HttpStatus.CONFLICT, "TRK_409_2", "현재 상태에서는 수행할 수 없는 작업입니다."),
115115
TRACKING_COURSE_MOUNTAIN_MISMATCH(HttpStatus.BAD_REQUEST, "TRK_400_1", "선택한 코스가 해당 산의 코스가 아닙니다."),
116116
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
117+
TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."),
118+
TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."),
117119
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),
118120

119121
/**

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ public enum SuccessStatus implements BaseStatus {
6262
TRACKING_SESSION_RESUME_SUCCESS(HttpStatus.OK, "TRK_200_5", "트래킹 세션을 재개했습니다."),
6363
TRACKING_SESSION_COMPLETE_SUCCESS(HttpStatus.OK, "TRK_200_6", "트래킹 세션을 종료했습니다."),
6464
TRACKING_SESSION_ABANDON_SUCCESS(HttpStatus.OK, "TRK_200_7", "트래킹 세션을 포기 처리했습니다."),
65+
TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."),
66+
TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."),
6567

6668
/**
6769
* Image

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ public enum NotificationType {
1313
"새 댓글이 달렸어요",
1414
"{actorName}: {commentPreview}",
1515
Set.of("actorName", "commentPreview")
16+
),
17+
18+
/**
19+
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
20+
* iOS 잠금화면에 title 은 앱 이름(SEMOSAN)이 자동 표시되므로 본 title 은 빈 문자열.
21+
*/
22+
TRACKING_PHOTO_MILESTONE(
23+
"",
24+
"{distance}m 돌파! 인증 사진을 남겨보세요!",
25+
Set.of("distance")
1626
);
1727

1828
private final String titleTemplate;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.semosan.api.domain.tracking.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.status.SuccessStatus;
5+
import com.semosan.api.domain.tracking.controller.docs.TrackingPhotoControllerDocs;
6+
import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest;
7+
import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse;
8+
import com.semosan.api.domain.tracking.service.TrackingPhotoService;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
import java.util.List;
16+
17+
@RestController
18+
@RequestMapping("/api/tracking/sessions/{sessionId}/photos")
19+
@RequiredArgsConstructor
20+
public class TrackingPhotoController implements TrackingPhotoControllerDocs {
21+
22+
private final TrackingPhotoService trackingPhotoService;
23+
24+
@PostMapping
25+
@Override
26+
public ResponseEntity<ApiResponse<TrackingPhotoResponse>> upload(
27+
@AuthenticationPrincipal Long userId,
28+
@PathVariable Long sessionId,
29+
@Valid @RequestBody TrackingPhotoUploadRequest request
30+
) {
31+
TrackingPhotoResponse response = trackingPhotoService.upload(userId, sessionId, request);
32+
return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_UPLOAD_SUCCESS, response);
33+
}
34+
35+
@GetMapping
36+
@Override
37+
public ResponseEntity<ApiResponse<List<TrackingPhotoResponse>>> list(
38+
@AuthenticationPrincipal Long userId,
39+
@PathVariable Long sessionId
40+
) {
41+
List<TrackingPhotoResponse> response = trackingPhotoService.listBySession(userId, sessionId);
42+
return ApiResponse.success(SuccessStatus.TRACKING_PHOTO_LIST_SUCCESS, response);
43+
}
44+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.semosan.api.domain.tracking.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest;
5+
import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.Parameter;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import jakarta.validation.Valid;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
18+
import java.util.List;
19+
20+
@Tag(name = "Tracking Photo", description = "트래킹 마일스톤 사진 메타 API")
21+
public interface TrackingPhotoControllerDocs {
22+
23+
@Operation(
24+
summary = "트래킹 마일스톤 사진 메타 업로드",
25+
description = "클라이언트가 MinIO 등 외부 스토리지(#41) 에 사진을 업로드한 직후 호출. "
26+
+ "본 API 는 이미지 바이너리를 받지 않으며 메타(URL, 좌표, 마일스톤 정보) 만 저장한다. "
27+
+ "해당 마일스톤에 이미 사진이 있으면 409, 종료된 세션이면 409."
28+
)
29+
@ApiResponses({
30+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "사진 메타 저장 성공"),
31+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "본인 세션 아님",
32+
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
33+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "세션 없음",
34+
content = @Content(schema = @Schema(implementation = ApiResponse.class))),
35+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
36+
description = "중복 마일스톤 또는 종료된 세션",
37+
content = @Content(schema = @Schema(implementation = ApiResponse.class)))
38+
})
39+
ResponseEntity<ApiResponse<TrackingPhotoResponse>> upload(
40+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
41+
@Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId,
42+
@Valid @RequestBody TrackingPhotoUploadRequest request
43+
);
44+
45+
@Operation(summary = "트래킹 세션의 사진 목록 조회",
46+
description = "본인 소유 세션의 마일스톤 사진을 milestone_index 오름차순으로 반환합니다.")
47+
ResponseEntity<ApiResponse<List<TrackingPhotoResponse>>> list(
48+
@Parameter(hidden = true) @AuthenticationPrincipal Long userId,
49+
@Parameter(description = "트래킹 세션 ID", required = true) @PathVariable Long sessionId
50+
);
51+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.semosan.api.domain.tracking.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.PositiveOrZero;
6+
7+
import java.time.LocalDateTime;
8+
9+
/**
10+
* 클라이언트가 MinIO 에 사진을 업로드한 직후, 백엔드에 메타를 통보하는 요청.
11+
* imageUrl 자체는 #41 presigned URL 흐름에서 얻는다.
12+
*/
13+
public record TrackingPhotoUploadRequest(
14+
@NotNull(message = "마일스톤 인덱스는 필수입니다.")
15+
@PositiveOrZero(message = "마일스톤 인덱스는 0 이상이어야 합니다.")
16+
Integer milestoneIndex,
17+
18+
@NotNull(message = "마일스톤 기준 거리(m)는 필수입니다.")
19+
Double milestoneDistanceM,
20+
21+
@NotBlank(message = "imageUrl 은 필수입니다.")
22+
String imageUrl,
23+
24+
@NotNull(message = "촬영 시각은 필수입니다.")
25+
LocalDateTime capturedAt,
26+
27+
@NotNull(message = "위도는 필수입니다.")
28+
Double lat,
29+
30+
@NotNull(message = "경도는 필수입니다.")
31+
Double lng,
32+
33+
Double altitude
34+
) {
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.semosan.api.domain.tracking.dto.response;
2+
3+
import com.semosan.api.domain.tracking.entity.TrackingPhoto;
4+
5+
import java.time.LocalDateTime;
6+
7+
public record TrackingPhotoResponse(
8+
Long photoId,
9+
Long trackingSessionId,
10+
Integer milestoneIndex,
11+
Double milestoneDistanceM,
12+
String imageUrl,
13+
LocalDateTime capturedAt,
14+
Double lat,
15+
Double lng,
16+
Double altitude
17+
) {
18+
19+
public static TrackingPhotoResponse from(TrackingPhoto photo) {
20+
return new TrackingPhotoResponse(
21+
photo.getId(),
22+
photo.getTrackingSession().getId(),
23+
photo.getMilestoneIndex(),
24+
photo.getMilestoneDistanceM(),
25+
photo.getImageUrl(),
26+
photo.getCapturedAt(),
27+
photo.getLat(),
28+
photo.getLng(),
29+
photo.getAltitude()
30+
);
31+
}
32+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.semosan.api.domain.tracking.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.AccessLevel;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Getter;
9+
import lombok.NoArgsConstructor;
10+
11+
import java.time.LocalDateTime;
12+
13+
/**
14+
* 트래킹 진행 중 마일스톤 도달 시 사용자가 촬영해 업로드한 사진의 메타.
15+
* 실제 이미지 바이너리는 MinIO 에 보관되고 본 테이블엔 URL 만 저장.
16+
* 한 트래킹 세션이 N 개의 사진(=마일스톤 수)을 가질 수 있다 — N 의 상한은 정책상 6.
17+
*/
18+
@Table(
19+
name = "tracking_photos",
20+
indexes = {
21+
@Index(name = "idx_tracking_photos_session", columnList = "tracking_session_id"),
22+
@Index(name = "idx_tracking_photos_session_milestone",
23+
columnList = "tracking_session_id, milestone_index")
24+
}
25+
)
26+
@Getter
27+
@Entity
28+
@Builder(access = AccessLevel.PROTECTED)
29+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
30+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
31+
public class TrackingPhoto extends BaseEntity {
32+
33+
@Id
34+
@GeneratedValue(strategy = GenerationType.IDENTITY)
35+
private Long id;
36+
37+
@ManyToOne(fetch = FetchType.LAZY)
38+
@JoinColumn(name = "tracking_session_id", nullable = false)
39+
private TrackingSession trackingSession;
40+
41+
/** 이 사진이 어떤 마일스톤에 해당하는지 (0-based, 코스 4컷 / 자유 최대 6컷). */
42+
@Column(name = "milestone_index", nullable = false)
43+
private Integer milestoneIndex;
44+
45+
/** 마일스톤의 기준 거리 (m). 예: 500, 1000, ... 또는 코스의 distance/4 등. */
46+
@Column(name = "milestone_distance_m", nullable = false)
47+
private Double milestoneDistanceM;
48+
49+
/** MinIO 등 외부 스토리지에 업로드된 이미지 URL (presigned URL 발급은 #41). */
50+
@Column(name = "image_url", nullable = false, length = 500)
51+
private String imageUrl;
52+
53+
@Column(name = "captured_at", nullable = false)
54+
private LocalDateTime capturedAt;
55+
56+
@Column(name = "lat", nullable = false)
57+
private Double lat;
58+
59+
@Column(name = "lng", nullable = false)
60+
private Double lng;
61+
62+
/** 촬영 시점 디바이스가 보고한 고도 (nullable). */
63+
@Column(name = "altitude")
64+
private Double altitude;
65+
66+
public static TrackingPhoto create(
67+
TrackingSession session,
68+
Integer milestoneIndex,
69+
Double milestoneDistanceM,
70+
String imageUrl,
71+
LocalDateTime capturedAt,
72+
Double lat,
73+
Double lng,
74+
Double altitude
75+
) {
76+
return TrackingPhoto.builder()
77+
.trackingSession(session)
78+
.milestoneIndex(milestoneIndex)
79+
.milestoneDistanceM(milestoneDistanceM)
80+
.imageUrl(imageUrl)
81+
.capturedAt(capturedAt)
82+
.lat(lat)
83+
.lng(lng)
84+
.altitude(altitude)
85+
.build();
86+
}
87+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.semosan.api.domain.tracking.repository;
2+
3+
import com.semosan.api.domain.tracking.entity.TrackingPhoto;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
import java.util.List;
7+
8+
public interface TrackingPhotoRepository extends JpaRepository<TrackingPhoto, Long> {
9+
10+
List<TrackingPhoto> findByTrackingSession_IdOrderByMilestoneIndexAsc(Long trackingSessionId);
11+
12+
boolean existsByTrackingSession_IdAndMilestoneIndex(Long trackingSessionId, Integer milestoneIndex);
13+
}
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+
}

0 commit comments

Comments
 (0)