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:
@@ -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("*");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user