Skip to content

Commit a740530

Browse files
committed
feat: 트래킹 사진 메타 업로드 API
1 parent 62396c6 commit a740530

5 files changed

Lines changed: 231 additions & 0 deletions

File tree

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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.semosan.api.domain.tracking.service;
2+
3+
import com.semosan.api.common.exception.GeneralException;
4+
import com.semosan.api.common.status.ErrorStatus;
5+
import com.semosan.api.domain.tracking.dto.request.TrackingPhotoUploadRequest;
6+
import com.semosan.api.domain.tracking.dto.response.TrackingPhotoResponse;
7+
import com.semosan.api.domain.tracking.entity.TrackingPhoto;
8+
import com.semosan.api.domain.tracking.entity.TrackingSession;
9+
import com.semosan.api.domain.tracking.enums.TrackingSessionStatus;
10+
import com.semosan.api.domain.tracking.repository.TrackingPhotoRepository;
11+
import com.semosan.api.domain.tracking.repository.TrackingSessionRepository;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
import java.util.EnumSet;
17+
import java.util.List;
18+
import java.util.Set;
19+
20+
@Service
21+
@RequiredArgsConstructor
22+
@Transactional(readOnly = true)
23+
public class TrackingPhotoService {
24+
25+
private static final Set<TrackingSessionStatus> ACTIVE_STATES =
26+
EnumSet.of(TrackingSessionStatus.IN_PROGRESS, TrackingSessionStatus.PAUSED);
27+
28+
private final TrackingPhotoRepository trackingPhotoRepository;
29+
private final TrackingSessionRepository trackingSessionRepository;
30+
31+
@Transactional
32+
public TrackingPhotoResponse upload(Long userId, Long sessionId, TrackingPhotoUploadRequest request) {
33+
TrackingSession session = findOwnedSession(userId, sessionId);
34+
if (!ACTIVE_STATES.contains(session.getStatus())) {
35+
throw new GeneralException(ErrorStatus.TRACKING_PHOTO_SESSION_INACTIVE);
36+
}
37+
if (trackingPhotoRepository.existsByTrackingSession_IdAndMilestoneIndex(sessionId, request.milestoneIndex())) {
38+
throw new GeneralException(ErrorStatus.TRACKING_PHOTO_DUPLICATE);
39+
}
40+
TrackingPhoto photo = TrackingPhoto.create(
41+
session,
42+
request.milestoneIndex(),
43+
request.milestoneDistanceM(),
44+
request.imageUrl(),
45+
request.capturedAt(),
46+
request.lat(),
47+
request.lng(),
48+
request.altitude()
49+
);
50+
TrackingPhoto saved = trackingPhotoRepository.save(photo);
51+
return TrackingPhotoResponse.from(saved);
52+
}
53+
54+
public List<TrackingPhotoResponse> listBySession(Long userId, Long sessionId) {
55+
findOwnedSession(userId, sessionId);
56+
return trackingPhotoRepository.findByTrackingSession_IdOrderByMilestoneIndexAsc(sessionId).stream()
57+
.map(TrackingPhotoResponse::from)
58+
.toList();
59+
}
60+
61+
private TrackingSession findOwnedSession(Long userId, Long sessionId) {
62+
TrackingSession session = trackingSessionRepository.findById(sessionId)
63+
.orElseThrow(() -> new GeneralException(ErrorStatus.TRACKING_SESSION_NOT_FOUND));
64+
if (!session.isOwnedBy(userId)) {
65+
throw new GeneralException(ErrorStatus.TRACKING_SESSION_FORBIDDEN);
66+
}
67+
return session;
68+
}
69+
}

0 commit comments

Comments
 (0)