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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.saferoute.domain.analysis.controller;

import com.saferoute.domain.floor.service.FloorService;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "AI 분석", description = "도면 AI 세그멘테이션 분석 요청 API")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.saferoute.domain.building.service.BuildingService;
import com.saferoute.global.api.response.ApiResponse;
import com.saferoute.global.api.response.BuildingSuccessCode;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

import java.util.List;
Expand All @@ -16,6 +17,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Tag(name = "건물", description = "건물 등록/조회/수정/삭제 API")
@RestController
@RequestMapping("/api/v1/buildings")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.saferoute.domain.training.dto.TrainingStatusResponse;
import com.saferoute.domain.report.service.TrainingReportService;
import com.saferoute.domain.training.service.TrainingSessionService;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand All @@ -15,6 +16,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "대시보드", description = "훈련 현황 및 통계 대시보드 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/dashboard")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.saferoute.domain.device.client;

import com.saferoute.domain.device.entity.IoTLightDirection;
import com.saferoute.global.api.error.IoTLightErrorCode;
import com.saferoute.global.api.exception.ApiException;
import java.time.Duration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

// Spring 서버가 라즈베리파이(Pi Flask 서버)에 유도등 방향 명령을 전달하는 책임을 전담한다.
// Pi 쪽 API 스펙: POST {piEndpoint}/lights/direction, body { lightCode, direction }
// 하드웨어(Pi) 구현은 아직 없어 실제 통신은 검증되지 않았다 - 스펙만 정의된 상태이며
// 로컬 Flask 스텁 서버로 대신 검증한다.
@Slf4j
@Component
public class IoTLightPiClient {

private static final Duration TIMEOUT = Duration.ofSeconds(3);
private static final String DIRECTION_PATH = "/lights/direction";

private final WebClient webClient;

public IoTLightPiClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
}

public void sendDirection(String piEndpoint, String lightCode, IoTLightDirection direction) {
try {
webClient.post()
.uri(piEndpoint + DIRECTION_PATH)
.bodyValue(new PiLightDirectionRequest(lightCode, direction))
.retrieve()
.toBodilessEntity()
.block(TIMEOUT);
} catch (RuntimeException exception) {
log.warn("라즈베리파이 통신 실패: piEndpoint={}, lightCode={}", piEndpoint, lightCode, exception);
throw new ApiException(IoTLightErrorCode.DEVICE_UNREACHABLE, exception);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.saferoute.domain.device.client;

import com.saferoute.domain.device.entity.IoTLightDirection;

// 라즈베리파이(Pi Flask 서버)로 전달하는 방향 명령 요청 body.
// lightCode는 IoTLight.code를 그대로 사용한다 - Pi 한 대가 릴레이 2대(분기점 2곳)를 담당하므로
// 어느 분기점에 대한 명령인지 Pi 쪽이 이 값으로 식별한다.
public record PiLightDirectionRequest(
String lightCode,
IoTLightDirection direction
) {}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.saferoute.domain.device.controller;

import com.saferoute.domain.device.dto.request.ChangeLightDirectionRequest;
import com.saferoute.domain.device.dto.request.ConfigureGuidanceRequest;
import com.saferoute.domain.device.dto.request.CreateIoTLightRequest;
import com.saferoute.domain.device.dto.request.UpdateIoTLightRequest;
import com.saferoute.domain.device.dto.request.UpdatePiEndpointRequest;
import com.saferoute.domain.device.dto.response.IoTLightResponse;
import com.saferoute.domain.device.dto.response.LightDirectionResponse;
import com.saferoute.domain.device.service.IoTLightService;
import com.saferoute.global.api.response.ApiResponse;
import com.saferoute.global.api.response.IoTLightSuccessCode;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
Expand All @@ -21,6 +25,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "IoT 유도등", description = "IoT 유도등 등록/조회/방향 제어 API")
@RestController
@RequestMapping("/api/v1/lights")
@RequiredArgsConstructor
Expand Down Expand Up @@ -69,6 +74,24 @@ public ResponseEntity<ApiResponse<IoTLightResponse>> updateLight(
return ResponseEntity.ok(ApiResponse.success(IoTLightSuccessCode.IOT_LIGHT_UPDATED, response));
}

@PatchMapping("/{lightId}/direction")
public ResponseEntity<ApiResponse<LightDirectionResponse>> changeDirection(
@PathVariable UUID lightId,
@Valid @RequestBody ChangeLightDirectionRequest request
) {
LightDirectionResponse response = iotLightService.changeDirection(lightId, request);
return ResponseEntity.ok(ApiResponse.success(IoTLightSuccessCode.IOT_LIGHT_DIRECTION_CHANGED, response));
}

@PatchMapping("/{lightId}/pi-endpoint")
public ResponseEntity<ApiResponse<IoTLightResponse>> updatePiEndpoint(
@PathVariable UUID lightId,
@Valid @RequestBody UpdatePiEndpointRequest request
) {
IoTLightResponse response = iotLightService.updatePiEndpoint(lightId, request);
return ResponseEntity.ok(ApiResponse.success(IoTLightSuccessCode.IOT_LIGHT_PI_ENDPOINT_UPDATED, response));
}

@PatchMapping("/{lightId}/enable")
public ResponseEntity<ApiResponse<IoTLightResponse>> enableLight(@PathVariable UUID lightId) {
IoTLightResponse response = iotLightService.enableLight(lightId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.saferoute.domain.device.dto.request;

import com.saferoute.domain.device.entity.IoTLightDirection;
import jakarta.validation.constraints.NotNull;

public record ChangeLightDirectionRequest(
@NotNull IoTLightDirection direction
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.saferoute.domain.device.dto.request;

import jakarta.validation.constraints.NotBlank;

public record UpdatePiEndpointRequest(
@NotBlank String piEndpoint
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public record IoTLightResponse(
UUID leftEdgeId,
UUID rightEdgeId,
boolean guidanceConfigured,
boolean enabled
boolean enabled,
String piEndpoint
) {
public static IoTLightResponse from(IoTLight light) {
return new IoTLightResponse(
Expand All @@ -28,7 +29,8 @@ public static IoTLightResponse from(IoTLight light) {
light.getLeftEdge() != null ? light.getLeftEdge().getId() : null,
light.getRightEdge() != null ? light.getRightEdge().getId() : null,
light.isGuidanceConfigured(),
light.isEnabled()
light.isEnabled(),
light.getPiEndpoint()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.saferoute.domain.device.dto.response;

import com.saferoute.domain.device.entity.IoTLightDirection;
import java.time.Instant;
import java.util.UUID;

public record LightDirectionResponse(
UUID lightId,
IoTLightDirection direction,
Instant updatedAt
) {}
13 changes: 13 additions & 0 deletions src/main/java/com/saferoute/domain/device/entity/IoTLight.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ public class IoTLight {
@Column(name = "enabled", nullable = false)
private boolean enabled;

// 이 유도등의 명령을 실제로 전달받는 라즈베리파이 주소 (예: http://192.168.0.50:5000)
// 등록 시점에는 비어있을 수 있음 - 하드웨어 배치 후 별도로 설정
@Column(name = "pi_endpoint", length = 255)
private String piEndpoint;

@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
Expand Down Expand Up @@ -134,6 +139,14 @@ public void enable() {
this.enabled = true;
}

// 하드웨어 배치/교체 시 관리자가 라즈베리파이 주소를 지정한다.
public void updatePiEndpoint(String piEndpoint) {
if (piEndpoint == null || piEndpoint.isBlank()) {
throw new IllegalArgumentException("piEndpoint는 비어있을 수 없습니다.");
}
this.piEndpoint = piEndpoint;
}

private static void validateCustomNode(MapNode customNode) {
if (customNode == null) {
throw new IllegalArgumentException("IoT 유도등의 customNode는 필수입니다.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.saferoute.domain.device.service;

import com.saferoute.domain.device.entity.IoTLightDirection;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;

// 유도등의 실시간 방향 상태를 보관한다. 훈련별로 바뀌는 동적 상태라 IoTLight 엔티티(DB)에는
// 저장하지 않고 서버 메모리에서만 관리한다 (IoTLight 클래스 상단 주석 참고).
// 서버가 재시작되면 초기화되는 것을 의도한 동작으로 본다 - 재시작 후에는 관리자가 방향을 다시 지정해야 한다.
@Component
public class IoTLightDirectionStore {

private final Map<UUID, IoTLightDirection> directions = new ConcurrentHashMap<>();

public void update(UUID lightId, IoTLightDirection direction) {
directions.put(lightId, direction);
}

public IoTLightDirection get(UUID lightId) {
return directions.getOrDefault(lightId, IoTLightDirection.OFF);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.saferoute.domain.device.service;

import com.saferoute.domain.device.client.IoTLightPiClient;
import com.saferoute.domain.device.dto.request.ChangeLightDirectionRequest;
import com.saferoute.domain.device.dto.request.ConfigureGuidanceRequest;
import com.saferoute.domain.device.dto.request.CreateIoTLightRequest;
import com.saferoute.domain.device.dto.request.UpdateIoTLightRequest;
import com.saferoute.domain.device.dto.request.UpdatePiEndpointRequest;
import com.saferoute.domain.device.dto.response.IoTLightResponse;
import com.saferoute.domain.device.dto.response.LightDirectionResponse;
import com.saferoute.domain.device.entity.IoTLight;
import com.saferoute.domain.device.entity.IoTLightDirection;
import com.saferoute.domain.device.repository.IoTLightJpaRepository;
import com.saferoute.domain.evacuation.graph.entity.MapEdge;
import com.saferoute.domain.evacuation.graph.entity.MapNode;
Expand All @@ -15,6 +20,8 @@
import com.saferoute.global.api.error.FloorErrorCode;
import com.saferoute.global.api.error.IoTLightErrorCode;
import com.saferoute.global.api.exception.ApiException;
import com.saferoute.infrastructure.websocket.service.TrainingEventPublisher;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand All @@ -30,6 +37,9 @@ public class IoTLightService {
private final MapNodeJpaRepository mapNodeJpaRepository;
private final MapEdgeJpaRepository mapEdgeJpaRepository;
private final FloorRepository floorRepository;
private final IoTLightPiClient iotLightPiClient;
private final IoTLightDirectionStore iotLightDirectionStore;
private final TrainingEventPublisher trainingEventPublisher;

@Transactional
public IoTLightResponse createLight(CreateIoTLightRequest request) {
Expand Down Expand Up @@ -102,6 +112,13 @@ public IoTLightResponse updateLight(UUID lightId, UpdateIoTLightRequest request)
return IoTLightResponse.from(light);
}

@Transactional
public IoTLightResponse updatePiEndpoint(UUID lightId, UpdatePiEndpointRequest request) {
IoTLight light = findLightOrThrow(lightId);
light.updatePiEndpoint(request.piEndpoint());
return IoTLightResponse.from(light);
}

@Transactional
public IoTLightResponse enableLight(UUID lightId) {
IoTLight light = findLightOrThrow(lightId);
Expand All @@ -116,6 +133,29 @@ public IoTLightResponse disableLight(UUID lightId) {
return IoTLightResponse.from(light);
}

// 검증(enabled/guidance) -> Pi 호출 -> 상태 저장 -> 이벤트 발행 순서로 조립한다.
// direction은 훈련별 동적 상태라 DB에 저장하지 않고 IoTLightDirectionStore(서버 메모리)에 저장한다 (IoTLight 참고).
public LightDirectionResponse changeDirection(UUID lightId, ChangeLightDirectionRequest request) {
IoTLight light = findLightOrThrow(lightId);
IoTLightDirection direction = request.direction();

if (!light.isEnabled()) {
throw new ApiException(IoTLightErrorCode.LIGHT_DISABLED);
}
if (direction != IoTLightDirection.OFF && !light.isGuidanceConfigured()) {
throw new ApiException(IoTLightErrorCode.GUIDANCE_NOT_CONFIGURED);
}
if (light.getPiEndpoint() == null || light.getPiEndpoint().isBlank()) {
throw new ApiException(IoTLightErrorCode.DEVICE_UNREACHABLE);
}

iotLightPiClient.sendDirection(light.getPiEndpoint(), light.getCode(), direction);
iotLightDirectionStore.update(light.getId(), direction);
trainingEventPublisher.publishIoTLightStatusUpdatedAfterCommit(light, direction);
Comment on lines +152 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

유도등별 명령 순서를 직렬화하세요.

동일 lightId에 대한 두 요청은 동시에 Pi 호출을 수행할 수 있습니다. 이전 요청의 Pi 응답이 늦게 도착하면 최신 요청이 저장한 방향을 이전 방향으로 덮어쓰고, 오래된 상태 이벤트를 발행할 수 있습니다. Pi의 실제 방향과 메모리 상태 및 WebSocket 이벤트가 불일치합니다.

Pi 응답 수신, 상태 저장, 이벤트 등록을 유도등별 단일 명령 큐 또는 순서 보장 가능한 버전 처리로 직렬화하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/saferoute/domain/device/service/IoTLightService.java`
around lines 152 - 154, Serialize commands per lightId in the flow containing
iotLightPiClient.sendDirection, iotLightDirectionStore.update, and
trainingEventPublisher.publishIoTLightStatusUpdatedAfterCommit so each light’s
Pi call, state update, and event registration execute in request order. Use a
per-light command queue or equivalent version/order validation, while allowing
commands for different lights to proceed independently and preventing stale
responses from overwriting state or publishing outdated events.


return new LightDirectionResponse(light.getId(), direction, Instant.now());
}

private IoTLight findLightOrThrow(UUID lightId) {
return iotLightJpaRepository.findById(lightId)
.orElseThrow(() -> new ApiException(IoTLightErrorCode.IOT_LIGHT_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.saferoute.domain.evacuation.service.EvacuationRouteService;
import com.saferoute.domain.evacuation.dto.response.EvacuationRouteResponse;
import com.saferoute.global.api.response.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -14,6 +15,7 @@
import org.springframework.web.bind.annotation.RestController;

// /graph(노드/엣지 편집용) 엔드포인트와 역할 분리를 위해 별도 컨트롤러로 구성 - MapGraphController 참고
@Tag(name = "대피 경로", description = "최단 대피 경로 조회 API")
@RestController
@RequestMapping("/api/v1/floors/{floorId}")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.saferoute.domain.evacuation.graph.dto.response.FloorGraphResponse;
import com.saferoute.domain.evacuation.graph.service.MapGraphService;
import com.saferoute.global.api.response.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand All @@ -11,6 +12,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "맵 그래프", description = "층별 맵 그래프(노드/엣지) 조회 API")
@RestController
@RequestMapping("/api/v1/floors/{floorId}")
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.saferoute.domain.evacuation.graph.service.MapGraphService;
import com.saferoute.global.api.response.ApiResponse;
import com.saferoute.global.api.response.EvacuationSuccessCode;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand All @@ -22,6 +23,7 @@
import org.springframework.web.bind.annotation.RestController;

// 커스텀 편집 UI(드래그로 노드 추가/이동, 엣지 연결)를 위한 CRUD 엔드포인트
@Tag(name = "맵 그래프 편집", description = "맵 그래프 노드/엣지 생성/수정/삭제 API")
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
Expand Down
Loading
Loading