From 7635412b7b067258d852db0b13a07701d40f4e35 Mon Sep 17 00:00:00 2001 From: gbpark Date: Sun, 3 May 2026 01:18:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SCADA=20=EC=8B=9C=EC=97=B0=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=95=8C=EB=9E=8C=20=EB=8F=99?= =?UTF-8?q?=EB=B0=98=20=ED=99=94=EB=A9=B4=20+=20Spring=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작업자 폰(/mobile)을 SCADA 데모와 ws 로 연결, 알람 발생 시 풀스크린 푸시 · v5 솔리드+글로우 톤, 진동/Web Audio 비프/Wake Lock/auto reconnect · 시연 안전망: ?test=1 자동 발동, 우상단 hidden 트리거 - backend: com.erp.alarm 신규 패키지 (WebSocketConfig + Handshake + Handler + Controller) · JWT 토큰 핸드셰이크 검증, userId 기반 채널 매핑 (멀티 디바이스 지원) · spring-boot-starter-websocket 의존성 추가 · path 를 /api/demo/* 안에 두어 Traefik 라우트 추가 불필요 + 정식 알람과 분리 - SCADA scenario.js 의 emergency 시퀀스(2700ms)에 fetch('/api/demo/alarm/trigger') 배선 · /scada?worker= query 로 target user 지정 (iframe src 로 전달) - 운영 시연 URL: siflex.invyone.com/mobile (siflex_user) ↔ /scada?worker=siflex_user Co-Authored-By: Claude Opus 4.7 (1M context) --- backend-spring/build.gradle | 1 + .../java/com/erp/alarm/AlarmController.java | 72 ++ .../erp/alarm/AlarmHandshakeInterceptor.java | 68 ++ .../com/erp/alarm/AlarmWebSocketHandler.java | 93 ++ .../java/com/erp/alarm/WebSocketConfig.java | 26 + frontend/app/(main)/scada/page.tsx | 35 + frontend/app/(mobile)/layout.tsx | 10 + .../app/(mobile)/mobile/MobileAlarmClient.tsx | 264 +++++ frontend/app/(mobile)/mobile/page.tsx | 5 + .../public/scada-demo/css/invyone-stage2.css | 963 ++++++++++++++++++ frontend/public/scada-demo/docs/PROGRESS.md | 562 ++++++++++ frontend/public/scada-demo/docs/README.md | 115 +++ frontend/public/scada-demo/img/officer.png | Bin 0 -> 4656143 bytes frontend/public/scada-demo/index.html | 353 +++++++ frontend/public/scada-demo/js/components.js | 566 ++++++++++ frontend/public/scada-demo/js/engine.js | 539 ++++++++++ frontend/public/scada-demo/js/main.js | 44 + frontend/public/scada-demo/js/scenario.js | 250 +++++ frontend/public/scada-demo/js/topology.js | 610 +++++++++++ frontend/public/scada-demo/js/ui.js | 513 ++++++++++ .../scada-demo/svg/carbon-cartridge-bank.svg | 104 ++ .../public/scada-demo/svg/pressure-gauge.svg | 70 ++ .../public/scada-demo/video/cctv-arrived.mp4 | Bin 0 -> 2055274 bytes frontend/public/scada-demo/video/cctv.mp4 | Bin 0 -> 1655860 bytes frontend/styles/mobile-alarm.css | 167 +++ 25 files changed, 5430 insertions(+) create mode 100644 backend-spring/src/main/java/com/erp/alarm/AlarmController.java create mode 100644 backend-spring/src/main/java/com/erp/alarm/AlarmHandshakeInterceptor.java create mode 100644 backend-spring/src/main/java/com/erp/alarm/AlarmWebSocketHandler.java create mode 100644 backend-spring/src/main/java/com/erp/alarm/WebSocketConfig.java create mode 100644 frontend/app/(main)/scada/page.tsx create mode 100644 frontend/app/(mobile)/layout.tsx create mode 100644 frontend/app/(mobile)/mobile/MobileAlarmClient.tsx create mode 100644 frontend/app/(mobile)/mobile/page.tsx create mode 100644 frontend/public/scada-demo/css/invyone-stage2.css create mode 100644 frontend/public/scada-demo/docs/PROGRESS.md create mode 100644 frontend/public/scada-demo/docs/README.md create mode 100644 frontend/public/scada-demo/img/officer.png create mode 100644 frontend/public/scada-demo/index.html create mode 100644 frontend/public/scada-demo/js/components.js create mode 100644 frontend/public/scada-demo/js/engine.js create mode 100644 frontend/public/scada-demo/js/main.js create mode 100644 frontend/public/scada-demo/js/scenario.js create mode 100644 frontend/public/scada-demo/js/topology.js create mode 100644 frontend/public/scada-demo/js/ui.js create mode 100644 frontend/public/scada-demo/svg/carbon-cartridge-bank.svg create mode 100644 frontend/public/scada-demo/svg/pressure-gauge.svg create mode 100644 frontend/public/scada-demo/video/cctv-arrived.mp4 create mode 100644 frontend/public/scada-demo/video/cctv.mp4 create mode 100644 frontend/styles/mobile-alarm.css 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 ( +