feat: SCADA 시연용 모바일 알람 동반 화면 + Spring WebSocket

- 작업자 폰(/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=<user_id> 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 01:18:12 +09:00
parent 517c42b5cb
commit 7635412b7b
25 changed files with 5430 additions and 0 deletions
@@ -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<ApiResponse<Map<String, Object>>> trigger(@RequestBody Map<String, Object> 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<String, Object> alarm = (Map<String, Object>) body.getOrDefault("alarm", new HashMap<>());
alarm.putIfAbsent("ts", System.currentTimeMillis());
int delivered = alarmHandler.sendAlarm(targetUserId, alarm);
Map<String, Object> 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<ApiResponse<Map<String, Object>>> status() {
Map<String, Object> result = Map.of(
"active_users", alarmHandler.activeUsers(),
"active_count", alarmHandler.activeUsers().size()
);
return ResponseEntity.ok(ApiResponse.success(result, "ok"));
}
}
@@ -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<String, Object> 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;
}
}
@@ -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<String, Set<WebSocketSession>> 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<String, Object> 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<WebSocketSession> 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<String, Object> alarm) {
Set<WebSocketSession> set = sessionsByUser.get(userId);
if (set == null || set.isEmpty()) {
log.info("[WS] no active session for user={}, skipped", userId);
return 0;
}
Map<String, Object> 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<String> activeUsers() {
return sessionsByUser.keySet();
}
}
@@ -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("*");
}
}