diff --git a/backend-spring/build.gradle b/backend-spring/build.gradle index 04407810..dd1792dd 100644 --- a/backend-spring/build.gradle +++ b/backend-spring/build.gradle @@ -24,6 +24,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/backend-spring/src/main/java/com/erp/alarm/AlarmController.java b/backend-spring/src/main/java/com/erp/alarm/AlarmController.java new file mode 100644 index 00000000..89b76bbf --- /dev/null +++ b/backend-spring/src/main/java/com/erp/alarm/AlarmController.java @@ -0,0 +1,72 @@ +package com.erp.alarm; + +import com.erp.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * SCADA 데모(또는 실제 PLC 게이트웨이) 가 호출해서 작업자 폰으로 알람을 push 하는 엔드포인트. + * 인증은 SecurityConfig 단계에선 permitAll 이지만, 시연 한정이므로 미인증 호출 허용. + * 향후 운영 단계로 가면 ROLE_SYSTEM 같은 별도 가드 필요. + */ +@RestController +@RequestMapping("/api/demo/alarm") +@RequiredArgsConstructor +@Slf4j +public class AlarmController { + + private final AlarmWebSocketHandler alarmHandler; + + /** + * POST /api/demo/alarm/trigger + * + * body: { + * "target_user_id": "ky", + * "alarm": { + * "code": "P-IN-HH", + * "severity": "CRITICAL", + * "title": "BW-A1 펌프 과압 / 누설 의심", + * "message": "...", + * "location": "펌프룸 A · BW-A1", + * "comp": "bw-a1" + * } + * } + */ + @PostMapping("/trigger") + public ResponseEntity>> trigger(@RequestBody Map body) { + String targetUserId = (String) body.get("target_user_id"); + if (!StringUtils.hasText(targetUserId)) { + return ResponseEntity.badRequest().body(ApiResponse.error("target_user_id 가 필요합니다.")); + } + @SuppressWarnings("unchecked") + Map alarm = (Map) body.getOrDefault("alarm", new HashMap<>()); + alarm.putIfAbsent("ts", System.currentTimeMillis()); + + int delivered = alarmHandler.sendAlarm(targetUserId, alarm); + + Map result = new HashMap<>(); + result.put("target_user_id", targetUserId); + result.put("delivered_to", delivered); + result.put("active_users", alarmHandler.activeUsers()); + + log.info("[Alarm] trigger user={}, delivered={}", targetUserId, delivered); + String msg = delivered > 0 ? "알람 전송됨 (" + delivered + "건)" : "수신자 미접속 — 알람 큐 없음"; + return ResponseEntity.ok(ApiResponse.success(result, msg)); + } + + /** GET /api/demo/alarm/status — 현재 ws 접속 사용자 목록 (디버그/시연 안전망) */ + @GetMapping("/status") + public ResponseEntity>> status() { + Map result = Map.of( + "active_users", alarmHandler.activeUsers(), + "active_count", alarmHandler.activeUsers().size() + ); + return ResponseEntity.ok(ApiResponse.success(result, "ok")); + } +} diff --git a/backend-spring/src/main/java/com/erp/alarm/AlarmHandshakeInterceptor.java b/backend-spring/src/main/java/com/erp/alarm/AlarmHandshakeInterceptor.java new file mode 100644 index 00000000..00b97c3f --- /dev/null +++ b/backend-spring/src/main/java/com/erp/alarm/AlarmHandshakeInterceptor.java @@ -0,0 +1,68 @@ +package com.erp.alarm; + +import com.erp.security.JwtTokenProvider; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AlarmHandshakeInterceptor implements HandshakeInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map attributes) { + String token = extractToken(request); + if (token == null || !jwtTokenProvider.validateToken(token)) { + log.warn("[WS] handshake rejected: invalid or missing token (uri={})", request.getURI()); + return false; + } + try { + Claims claims = jwtTokenProvider.getClaims(token); + String userId = claims.get("user_id", String.class); + String companyCode = claims.get("company_code", String.class); + if (userId == null) { + log.warn("[WS] handshake rejected: token has no user_id"); + return false; + } + attributes.put("user_id", userId); + attributes.put("company_code", companyCode); + log.info("[WS] handshake ok: user={}, company={}", userId, companyCode); + return true; + } catch (Exception e) { + log.warn("[WS] handshake rejected: claims parse failed - {}", e.getMessage()); + return false; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // no-op + } + + private String extractToken(ServerHttpRequest request) { + // 브라우저 WebSocket API 는 커스텀 헤더를 못 붙이므로 query string ?token= 우선. + // 비-브라우저 클라이언트(서버↔서버) 를 위해 Authorization: Bearer 도 fallback 으로 허용. + if (request instanceof ServletServerHttpRequest sreq) { + String q = sreq.getServletRequest().getParameter("token"); + if (q != null && !q.isBlank()) return q; + } + String auth = request.getHeaders().getFirst("Authorization"); + if (auth != null && auth.startsWith("Bearer ")) { + return auth.substring(7); + } + return null; + } +} diff --git a/backend-spring/src/main/java/com/erp/alarm/AlarmWebSocketHandler.java b/backend-spring/src/main/java/com/erp/alarm/AlarmWebSocketHandler.java new file mode 100644 index 00000000..3ec46182 --- /dev/null +++ b/backend-spring/src/main/java/com/erp/alarm/AlarmWebSocketHandler.java @@ -0,0 +1,93 @@ +package com.erp.alarm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +public class AlarmWebSocketHandler extends TextWebSocketHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // userId -> set of sessions (한 사용자가 여러 디바이스/탭 동시 접속 가능) + private final Map> sessionsByUser = new ConcurrentHashMap<>(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + String userId = (String) session.getAttributes().get("user_id"); + if (userId == null) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + sessionsByUser.computeIfAbsent(userId, k -> ConcurrentHashMap.newKeySet()).add(session); + log.info("[WS] connected: user={}, sessions={}", userId, sessionsByUser.get(userId).size()); + + Map hello = Map.of("type", "hello", "user_id", userId, "ts", System.currentTimeMillis()); + session.sendMessage(new TextMessage(objectMapper.writeValueAsString(hello))); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + String userId = (String) session.getAttributes().get("user_id"); + if (userId == null) return; + Set set = sessionsByUser.get(userId); + if (set != null) { + set.remove(session); + if (set.isEmpty()) sessionsByUser.remove(userId); + } + log.info("[WS] closed: user={}, status={}", userId, status); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + // 폰 → 서버 메시지 (예: ack). 시연 단계에선 ack 만 받아 로그. + String userId = (String) session.getAttributes().get("user_id"); + log.info("[WS] msg from {}: {}", userId, message.getPayload()); + } + + /** + * 특정 user_id 의 모든 active session 에 알람 push. + * 반환값 = 실제로 메시지가 송신된 세션 수 (0 이면 미접속). + */ + public int sendAlarm(String userId, Map alarm) { + Set set = sessionsByUser.get(userId); + if (set == null || set.isEmpty()) { + log.info("[WS] no active session for user={}, skipped", userId); + return 0; + } + Map envelope = Map.of("type", "alarm", "payload", alarm); + String json; + try { + json = objectMapper.writeValueAsString(envelope); + } catch (Exception e) { + log.error("[WS] serialize fail", e); + return 0; + } + int sent = 0; + for (WebSocketSession s : set) { + try { + if (s.isOpen()) { + s.sendMessage(new TextMessage(json)); + sent++; + } + } catch (Exception e) { + log.warn("[WS] send fail to {}: {}", userId, e.getMessage()); + } + } + return sent; + } + + /** 디버그/모니터링 용 */ + public Set activeUsers() { + return sessionsByUser.keySet(); + } +} diff --git a/backend-spring/src/main/java/com/erp/alarm/WebSocketConfig.java b/backend-spring/src/main/java/com/erp/alarm/WebSocketConfig.java new file mode 100644 index 00000000..a280fefe --- /dev/null +++ b/backend-spring/src/main/java/com/erp/alarm/WebSocketConfig.java @@ -0,0 +1,26 @@ +package com.erp.alarm; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final AlarmWebSocketHandler alarmHandler; + private final AlarmHandshakeInterceptor handshakeInterceptor; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + // path 를 /api/demo/* 안에 두는 이유: + // 운영 Traefik 라우트가 PathPrefix(`/api`) 만 backend 로 보내므로 별도 라우트 추가가 필요 없음. + // /demo/* 는 시연용임을 path 에서 명시 — 정식 알람 시스템(/api/alarm/*)과 분리. + registry.addHandler(alarmHandler, "/api/demo/ws/alarm") + .addInterceptors(handshakeInterceptor) + .setAllowedOriginPatterns("*"); + } +} diff --git a/frontend/app/(main)/scada/page.tsx b/frontend/app/(main)/scada/page.tsx new file mode 100644 index 00000000..175b0f1f --- /dev/null +++ b/frontend/app/(main)/scada/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; + +function ScadaIframe() { + const params = useSearchParams(); + // ?worker= 를 iframe 으로 전달해서 SCADA emergency 시 그 사용자 폰으로 알람 push. + // 미지정이면 로컬 시연 모드 (폰 push 없음). + const worker = params.get("worker") ?? ""; + const src = worker + ? `/scada-demo/index.html?worker=${encodeURIComponent(worker)}` + : "/scada-demo/index.html"; + + return ( +