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
@@ -1,8 +1,10 @@
package com.saferoute.domain.congestion.controller;

import com.saferoute.domain.congestion.dto.request.ReportCongestionRequest;
import com.saferoute.domain.congestion.dto.request.ConnectEventImageRequest;
import com.saferoute.domain.congestion.dto.response.ObservationResponse;
import com.saferoute.domain.congestion.service.CongestionEventService;
import com.saferoute.domain.congestion.service.CongestionEventImageService;
import com.saferoute.domain.device.service.DeviceAuthorizationService;
import com.saferoute.domain.telemetry.dynamo.entity.ObservationItem;
import com.saferoute.domain.telemetry.dynamo.repository.IdempotentSaveResult;
Expand All @@ -14,9 +16,12 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;

@Tag(name = "혼잡도", description = "CCTV 혼잡 이벤트 수신 API")
@RestController
Expand All @@ -25,6 +30,7 @@
public class CongestionController {

private final CongestionEventService congestionEventService;
private final CongestionEventImageService congestionEventImageService;
private final DeviceAuthorizationService deviceAuthorizationService;

@PostMapping
Expand All @@ -38,4 +44,14 @@ public ResponseEntity<ObservationResponse> reportCongestion(
HttpStatus status = saveResult.created() ? HttpStatus.CREATED : HttpStatus.OK;
return ResponseEntity.status(status).body(response);
}

@PatchMapping("/{eventId}/image")
public ResponseEntity<Void> connectEventImage(
@AuthenticationPrincipal DevicePrincipal principal,
@PathVariable UUID eventId,
@Valid @RequestBody ConnectEventImageRequest request
) {
congestionEventImageService.connectImage(principal, eventId, request);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.saferoute.domain.congestion.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;

public record ConnectEventImageRequest(
@NotBlank String eventImageKey,
@NotNull @PositiveOrZero Long uploadedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.saferoute.domain.congestion.service;

import com.saferoute.domain.congestion.dto.request.ConnectEventImageRequest;
import com.saferoute.domain.device.service.DeviceAuthorizationService;
import com.saferoute.domain.telemetry.dynamo.entity.EventProcessingStatus;
import com.saferoute.domain.telemetry.dynamo.entity.ImageUploadStatus;
import com.saferoute.domain.telemetry.dynamo.entity.ObservationItem;
import com.saferoute.domain.telemetry.dynamo.repository.ObservationRepository;
import com.saferoute.global.api.error.CongestionErrorCode;
import com.saferoute.global.api.exception.ApiException;
import com.saferoute.global.security.DevicePrincipal;
import com.saferoute.infrastructure.s3.service.S3Service;
import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher;
import java.util.Objects;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CongestionEventImageService {

private static final String TRAINING_PREFIX = "training";
private static final String EVENTS_DIRECTORY = "events";
private static final String JPEG_SUFFIX = ".jpg";

private final ObservationRepository observationRepository;
private final DeviceAuthorizationService deviceAuthorizationService;
private final S3Service s3Service;
private final TrainingEventPublisher trainingEventPublisher;

public void connectImage(
DevicePrincipal principal,
UUID eventId,
ConnectEventImageRequest request
) {
ObservationItem item = findEvent(eventId);
deviceAuthorizationService.validateCctv(principal, item.getCctvCode());
validateEventProcessed(item);
validateObjectKey(item, eventId, request.eventImageKey());

if (isSameCompletedImage(item, request)) {
publishImageUpdated(item);
return;
}
validateImageState(item);

if (!s3Service.objectExists(request.eventImageKey())) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_OBJECT_NOT_FOUND);
}

if (!observationRepository.completeImageUpload(
eventId.toString(), request.eventImageKey(), request.uploadedAt()
)) {
ObservationItem latest = findEvent(eventId);
if (!isSameCompletedImage(latest, request)) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_STATE_CONFLICT);
}
publishImageUpdated(latest);
return;
}

item.setEventImageKey(request.eventImageKey());
item.setImageUploadedAt(request.uploadedAt());
item.setImageUploadStatus(ImageUploadStatus.COMPLETED);
publishImageUpdated(item);
}

private ObservationItem findEvent(UUID eventId) {
return observationRepository.findByEventId(eventId.toString())
.orElseThrow(() -> new ApiException(CongestionErrorCode.EVENT_NOT_FOUND));
}

private void validateEventProcessed(ObservationItem item) {
if (item.getEventStatus() != EventProcessingStatus.PROCESSED) {
throw new ApiException(CongestionErrorCode.EVENT_NOT_PROCESSED);
}
}

private void validateImageState(ObservationItem item) {
if (item.getImageUploadStatus() != null
&& item.getImageUploadStatus() != ImageUploadStatus.PENDING
&& item.getImageUploadStatus() != ImageUploadStatus.FAILED) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_STATE_CONFLICT);
}
}

private void validateObjectKey(ObservationItem item, UUID eventId, String objectKey) {
String[] segments = objectKey.split("/", -1);
if (segments.length != 5
|| !TRAINING_PREFIX.equals(segments[0])
|| !EVENTS_DIRECTORY.equals(segments[2])
|| !segments[4].endsWith(JPEG_SUFFIX)) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_KEY_INVALID);
}

UUID sessionId = parseCanonicalUuid(segments[1]);
String imageEventId = segments[4].substring(0, segments[4].length() - JPEG_SUFFIX.length());
UUID keyEventId = parseCanonicalUuid(imageEventId);
boolean sameIdentity = Objects.equals(item.getTrainingSessionId(), sessionId.toString())
&& Objects.equals(item.getCctvCode(), segments[3])
&& eventId.equals(keyEventId);
if (!sameIdentity) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_IDENTITY_MISMATCH);
}
}

private UUID parseCanonicalUuid(String value) {
try {
UUID uuid = UUID.fromString(value);
if (!uuid.toString().equals(value)) {
throw new IllegalArgumentException("non-canonical UUID");
}
return uuid;
} catch (IllegalArgumentException exception) {
throw new ApiException(CongestionErrorCode.EVENT_IMAGE_KEY_INVALID, exception);
}
}

private boolean isSameCompletedImage(
ObservationItem item,
ConnectEventImageRequest request
) {
return isCompletedStatus(item.getImageUploadStatus())
&& Objects.equals(item.getEventImageKey(), request.eventImageKey())
&& Objects.equals(item.getImageUploadedAt(), request.uploadedAt());
}

@SuppressWarnings("deprecation")
private boolean isCompletedStatus(ImageUploadStatus status) {
return status == ImageUploadStatus.COMPLETED || status == ImageUploadStatus.UPLOADED;
}

private void publishImageUpdated(ObservationItem item) {
trainingEventPublisher.publishCongestionImageUpdated(
UUID.fromString(item.getTrainingSessionId()),
item
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

public enum ImageUploadStatus {
PENDING,
UPLOADED,
FAILED
COMPLETED,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
FAILED,

/** 기존 DynamoDB 항목 역직렬화를 위한 호환 값. 신규 저장에는 사용하지 않는다. */
@Deprecated
UPLOADED
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public class ObservationItem {
private Long windowEnd;
private Long capturedAt;
private String monitoringImageKey;
private String eventImageKey;
private Long imageUploadedAt;
private ImageUploadStatus imageUploadStatus;
private Long configVersion;
private Long expiresAt;
private EventProcessingStatus eventStatus;
Expand Down Expand Up @@ -72,6 +75,7 @@ public static ObservationItem create(
item.windowEnd = windowEnd;
item.capturedAt = capturedAt;
item.monitoringImageKey = monitoringImageKey;
item.imageUploadStatus = ImageUploadStatus.PENDING;
item.configVersion = configVersion;
item.expiresAt = Math.floorDiv(capturedAt, 1_000L) + TTL_SECONDS;
item.eventStatus = EventProcessingStatus.RECEIVED;
Expand Down Expand Up @@ -236,6 +240,30 @@ public void setMonitoringImageKey(String monitoringImageKey) {
this.monitoringImageKey = monitoringImageKey;
}

public String getEventImageKey() {
return eventImageKey;
}

public void setEventImageKey(String eventImageKey) {
this.eventImageKey = eventImageKey;
}

public Long getImageUploadedAt() {
return imageUploadedAt;
}

public void setImageUploadedAt(Long imageUploadedAt) {
this.imageUploadedAt = imageUploadedAt;
}

public ImageUploadStatus getImageUploadStatus() {
return imageUploadStatus;
}

public void setImageUploadStatus(ImageUploadStatus imageUploadStatus) {
this.imageUploadStatus = imageUploadStatus;
}

public Long getConfigVersion() {
return configVersion;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,15 @@ private void validateEventStatusTransition(
}
}

@SuppressWarnings("deprecation")
private void validateImageStatusTransition(
ImageUploadStatus expectedStatus,
ImageUploadStatus newStatus
) {
boolean complete = expectedStatus == ImageUploadStatus.PENDING
&& (newStatus == ImageUploadStatus.UPLOADED || newStatus == ImageUploadStatus.FAILED);
&& (newStatus == ImageUploadStatus.COMPLETED
|| newStatus == ImageUploadStatus.UPLOADED
|| newStatus == ImageUploadStatus.FAILED);
boolean retry = expectedStatus == ImageUploadStatus.FAILED
&& newStatus == ImageUploadStatus.PENDING;
if (!complete && !retry) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.saferoute.domain.telemetry.dynamo.repository;

import com.saferoute.domain.telemetry.dynamo.entity.EventProcessingStatus;
import com.saferoute.domain.telemetry.dynamo.entity.ImageUploadStatus;
import com.saferoute.domain.telemetry.dynamo.entity.ObservationItem;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -114,6 +115,26 @@ public boolean failProcessing(String eventId, String processingOwner) {
);
}

public boolean completeImageUpload(String eventId, String eventImageKey, long uploadedAt) {
ObservationItem item = processingUpdateItem(eventId);
item.setEventImageKey(eventImageKey);
item.setImageUploadedAt(uploadedAt);
item.setImageUploadStatus(ImageUploadStatus.COMPLETED);

Expression condition = Expression.builder()
.expression("attribute_exists(#pk) AND #eventStatus = :processed"
+ " AND (attribute_not_exists(#imageStatus)"
+ " OR #imageStatus = :pending OR #imageStatus = :failed)")
.putExpressionName("#pk", "pk")
.putExpressionName("#eventStatus", "eventStatus")
.putExpressionName("#imageStatus", "imageUploadStatus")
.putExpressionValue(":processed", AttributeValue.fromS("PROCESSED"))
.putExpressionValue(":pending", AttributeValue.fromS("PENDING"))
.putExpressionValue(":failed", AttributeValue.fromS("FAILED"))
.build();
return updateConditionally(item, condition);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public List<ObservationItem> findAllBySessionIdAndCctvCode(
String trainingSessionId,
String cctvCode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ public enum CongestionErrorCode implements BaseErrorCode {
HttpStatus.CONFLICT,
"CONGESTION002",
"동일한 eventId에 다른 세션, CCTV 또는 경로 정보가 전달되었습니다."
),
EVENT_NOT_FOUND(
HttpStatus.NOT_FOUND,
"CONGESTION003",
"혼잡 이벤트를 찾을 수 없습니다."
),
EVENT_NOT_PROCESSED(
HttpStatus.CONFLICT,
"CONGESTION004",
"처리가 완료된 혼잡 이벤트에만 이미지를 연결할 수 있습니다."
),
EVENT_IMAGE_STATE_CONFLICT(
HttpStatus.CONFLICT,
"CONGESTION005",
"현재 이미지 상태에서는 이미지를 연결할 수 없습니다."
),
EVENT_IMAGE_KEY_INVALID(
HttpStatus.BAD_REQUEST,
"CONGESTION006",
"이벤트 이미지 경로 형식이 올바르지 않습니다."
),
EVENT_IMAGE_IDENTITY_MISMATCH(
HttpStatus.CONFLICT,
"CONGESTION007",
"이미지 경로의 세션, CCTV 또는 eventId가 이벤트와 일치하지 않습니다."
),
EVENT_IMAGE_OBJECT_NOT_FOUND(
HttpStatus.CONFLICT,
"CONGESTION008",
"업로드가 완료된 이벤트 이미지 객체를 찾을 수 없습니다."
);

private final HttpStatus httpStatus;
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/saferoute/global/api/error/S3ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public enum S3ErrorCode implements BaseErrorCode {
HttpStatus.INTERNAL_SERVER_ERROR,
"S3_ERROR_003",
"S3 업로드 URL 발급에 실패했습니다."
),
OBJECT_CHECK_FAILED(
HttpStatus.SERVICE_UNAVAILABLE,
"S3_ERROR_004",
"S3 객체 확인에 실패했습니다. 잠시 후 다시 시도해 주세요."
);

private final HttpStatus httpStatus;
Expand Down
Loading
Loading