|
|
@@ -4,8 +4,14 @@ import com.alibaba.fastjson.JSONArray;
|
|
|
import com.alibaba.fastjson.JSONObject;
|
|
|
import com.zksy.base.domain.EquipmentStatus;
|
|
|
import com.zksy.base.service.EquipmentStatusService;
|
|
|
+import com.zksy.base.domain.vo.WorkOrderPushVO;
|
|
|
+import com.zksy.base.service.BusinessWorkOrderService;
|
|
|
+import com.zksy.common.core.domain.model.LoginUser;
|
|
|
+import com.zksy.common.utils.DateUtils;
|
|
|
+import com.zksy.framework.web.service.TokenService;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
import org.springframework.stereotype.Component;
|
|
|
import org.springframework.web.socket.CloseStatus;
|
|
|
import org.springframework.web.socket.TextMessage;
|
|
|
@@ -14,8 +20,11 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|
|
|
|
|
import java.io.IOException;
|
|
|
import java.util.HashSet;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Collections;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
import java.util.Set;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
|
|
|
@@ -24,14 +33,20 @@ import java.util.concurrent.ConcurrentHashMap;
|
|
|
* <p>
|
|
|
* 支持的消息类型(通过 JSON 中的 type 字段区分):
|
|
|
* <ul>
|
|
|
+ * <li><b>authenticate</b> — 使用登录 JWT 鉴权(工单推送必需)</li>
|
|
|
* <li><b>subscribe</b> — 批量订阅设备,后端状态变更时主动推送</li>
|
|
|
* <li><b>unsubscribe</b> — 取消订阅设备</li>
|
|
|
* <li><b>deviceStatus</b> — 单次查询设备状态(兼容旧接口)</li>
|
|
|
+ * <li><b>workOrderReminder</b> — 查询当前用户可见的待处理/超时工单</li>
|
|
|
* <li><b>ping</b> — 心跳检测</li>
|
|
|
* </ul>
|
|
|
*
|
|
|
* <h3>协议示例</h3>
|
|
|
* <pre>
|
|
|
+ * // 工单鉴权(连接后首帧发送)
|
|
|
+ * → {"type":"authenticate","token":"Bearer <JWT>"}
|
|
|
+ * ← {"type":"authenticated","success":true,"data":{"userId":10,"deptId":100}}
|
|
|
+ *
|
|
|
* // 订阅
|
|
|
* → {"type":"subscribe","deviceCodes":["DEV001","DEV002"]}
|
|
|
* ← {"type":"subscribed","deviceCodes":["DEV001","DEV002"]}
|
|
|
@@ -49,7 +64,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
|
|
* ← {"type":"deviceStatus","success":true,"data":{...}}
|
|
|
*
|
|
|
* // 设备状态变更推送(服务端主动推送)
|
|
|
- * ← {"type":"deviceStatusPush","data":{...}}
|
|
|
+ * ← {"type":"deviceStatusPush","deviceCode":"DEV001","data":{...}}
|
|
|
+ *
|
|
|
+ * // 工单提醒推送(服务端每分钟扫描,有变化才推送)
|
|
|
+ * ← {"type":"workOrderReminderPush","success":true,"data":{"items":[...],"total":1}}
|
|
|
* </pre>
|
|
|
*
|
|
|
* @author zksy
|
|
|
@@ -64,6 +82,18 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
@Autowired
|
|
|
private SubscriptionManager subscriptionManager;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private TokenService tokenService;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private BusinessWorkOrderService businessWorkOrderService;
|
|
|
+
|
|
|
+ @Value("${websocket.work-order.max-items:100}")
|
|
|
+ private int workOrderMaxItems;
|
|
|
+
|
|
|
+ static final String WORK_ORDER_LOGIN_USER_ATTRIBUTE = "workOrderLoginUser";
|
|
|
+ static final String WORK_ORDER_TOKEN_ATTRIBUTE = "workOrderToken";
|
|
|
+
|
|
|
/**
|
|
|
* 维护所有在线连接,key 为 sessionId
|
|
|
* 供 RedisListener 跨线程查找会话
|
|
|
@@ -77,6 +107,42 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
return SESSION_MAP.get(sessionId);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 获取当前在线会话快照,供定时推送任务安全遍历。
|
|
|
+ */
|
|
|
+ public Map<String, WebSocketSession> getSessionSnapshot() {
|
|
|
+ return new HashMap<>(SESSION_MAP);
|
|
|
+ }
|
|
|
+
|
|
|
+ public LoginUser getAuthenticatedUser(WebSocketSession session) {
|
|
|
+ if (session == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Map<String, Object> attributes = session.getAttributes();
|
|
|
+ if (attributes == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ Object token;
|
|
|
+ Object value;
|
|
|
+ synchronized (session) {
|
|
|
+ token = attributes.get(WORK_ORDER_TOKEN_ATTRIBUTE);
|
|
|
+ value = attributes.get(WORK_ORDER_LOGIN_USER_ATTRIBUTE);
|
|
|
+ }
|
|
|
+ if (token instanceof String && !((String) token).trim().isEmpty()) {
|
|
|
+ LoginUser loginUser = tokenService.getLoginUserByToken((String) token);
|
|
|
+ if (loginUser == null) {
|
|
|
+ clearAuthentication(session);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ tokenService.verifyToken(loginUser);
|
|
|
+ synchronized (session) {
|
|
|
+ attributes.put(WORK_ORDER_LOGIN_USER_ATTRIBUTE, loginUser);
|
|
|
+ }
|
|
|
+ return loginUser;
|
|
|
+ }
|
|
|
+ return value instanceof LoginUser ? (LoginUser) value : null;
|
|
|
+ }
|
|
|
+
|
|
|
@Override
|
|
|
public void afterConnectionEstablished(WebSocketSession session) {
|
|
|
String sessionId = session.getId();
|
|
|
@@ -87,11 +153,11 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
@Override
|
|
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
|
|
|
String payload = message.getPayload();
|
|
|
- log.debug("收到 WebSocket 消息: sessionId={}, payload={}", session.getId(), payload);
|
|
|
|
|
|
try {
|
|
|
JSONObject request = JSONObject.parseObject(payload);
|
|
|
String type = request.getString("type");
|
|
|
+ log.debug("收到 WebSocket 消息: sessionId={}, type={}", session.getId(), type);
|
|
|
|
|
|
if (type == null || type.isEmpty()) {
|
|
|
sendError(session, "消息缺少 type 字段");
|
|
|
@@ -100,6 +166,12 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
|
|
|
// === 根据 type 分发到不同的业务处理 ===
|
|
|
switch (type) {
|
|
|
+ case "authenticate":
|
|
|
+ handleAuthenticate(session, request);
|
|
|
+ break;
|
|
|
+ case "workOrderReminder":
|
|
|
+ handleWorkOrderReminder(session);
|
|
|
+ break;
|
|
|
//订阅
|
|
|
case "subscribe":
|
|
|
handleSubscribe(session, request);
|
|
|
@@ -126,6 +198,65 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * WebSocket 工单推送鉴权。浏览器 WebSocket 无法设置 Authorization 请求头,
|
|
|
+ * 因此客户端连接成功后需先发送 {"type":"authenticate","token":"..."}。
|
|
|
+ */
|
|
|
+ private void handleAuthenticate(WebSocketSession session, JSONObject request) throws IOException {
|
|
|
+ String token = request.getString("token");
|
|
|
+ LoginUser loginUser = tokenService.getLoginUserByToken(token);
|
|
|
+ if (loginUser == null) {
|
|
|
+ clearAuthentication(session);
|
|
|
+ sendResponse(session, "authenticated", false, null, "token 无效或已过期");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ tokenService.verifyToken(loginUser);
|
|
|
+ synchronized (session) {
|
|
|
+ session.getAttributes().put(WORK_ORDER_LOGIN_USER_ATTRIBUTE, loginUser);
|
|
|
+ session.getAttributes().put(WORK_ORDER_TOKEN_ATTRIBUTE, token);
|
|
|
+ }
|
|
|
+ JSONObject data = new JSONObject();
|
|
|
+ data.put("userId", loginUser.getUserId());
|
|
|
+ data.put("deptId", loginUser.getDeptId());
|
|
|
+ sendResponse(session, "authenticated", true, data, null);
|
|
|
+ log.info("WebSocket 工单推送鉴权成功: sessionId={}, userId={}, deptId={}",
|
|
|
+ session.getId(), loginUser.getUserId(), loginUser.getDeptId());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void handleWorkOrderReminder(WebSocketSession session) throws IOException {
|
|
|
+ LoginUser loginUser = getAuthenticatedUser(session);
|
|
|
+ if (loginUser == null) {
|
|
|
+ sendResponse(session, "workOrderReminder", false, null, "请先完成 WebSocket 鉴权");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ List<WorkOrderPushVO> candidates = businessWorkOrderService.selectPendingOrOverdueWorkOrders(new java.util.Date());
|
|
|
+ if (candidates == null) {
|
|
|
+ candidates = Collections.emptyList();
|
|
|
+ }
|
|
|
+ List<WorkOrderPushVO> visibleItems = candidates
|
|
|
+ .stream()
|
|
|
+ .filter(item -> isWorkOrderVisibleTo(item, loginUser))
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ int limit = Math.max(1, workOrderMaxItems);
|
|
|
+ List<WorkOrderPushVO> items = visibleItems.stream().limit(limit).collect(Collectors.toList());
|
|
|
+ JSONObject data = new JSONObject();
|
|
|
+ data.put("items", items);
|
|
|
+ data.put("total", visibleItems.size());
|
|
|
+ data.put("truncated", visibleItems.size() > items.size());
|
|
|
+ data.put("serverTime", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new java.util.Date()));
|
|
|
+ sendResponse(session, "workOrderReminder", true, data, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ static boolean isWorkOrderVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
|
|
|
+ if (item == null || loginUser == null) return false;
|
|
|
+ boolean sameDepartment = loginUser.getDeptId() != null && loginUser.getDeptId().equals(item.getDeptId());
|
|
|
+ boolean receiver = item.getReceiveUser() != null && loginUser.getUserId() != null
|
|
|
+ && java.util.Arrays.stream(item.getReceiveUser().split(","))
|
|
|
+ .map(String::trim).anyMatch(String.valueOf(loginUser.getUserId())::equals);
|
|
|
+ return sameDepartment || receiver;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 处理批量订阅
|
|
|
*/
|
|
|
@@ -154,7 +285,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
JSONObject response = new JSONObject();
|
|
|
response.put("type", "subscribed");
|
|
|
response.put("deviceCodes", subscribed);
|
|
|
- session.sendMessage(new TextMessage(response.toJSONString()));
|
|
|
+ sendText(session, response.toJSONString());
|
|
|
|
|
|
log.info("订阅成功: sessionId={}, 订阅设备={}", session.getId(), subscribed);
|
|
|
}
|
|
|
@@ -188,7 +319,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
response.put("type", "unsubscribed");
|
|
|
response.put("deviceCodes", deviceCodes);
|
|
|
response.put("remaining", remaining);
|
|
|
- session.sendMessage(new TextMessage(response.toJSONString()));
|
|
|
+ sendText(session, response.toJSONString());
|
|
|
|
|
|
log.info("取消订阅: sessionId={}, 取消设备={}, 剩余订阅={}", session.getId(), deviceCodes, remaining);
|
|
|
}
|
|
|
@@ -220,13 +351,14 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
JSONObject response = new JSONObject();
|
|
|
response.put("type", "pong");
|
|
|
response.put("timestamp", System.currentTimeMillis());
|
|
|
- session.sendMessage(new TextMessage(response.toJSONString()));
|
|
|
+ sendText(session, response.toJSONString());
|
|
|
}
|
|
|
|
|
|
@Override
|
|
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
|
|
String sessionId = session.getId();
|
|
|
SESSION_MAP.remove(sessionId);
|
|
|
+ clearAuthentication(session);
|
|
|
subscriptionManager.removeSession(sessionId);
|
|
|
log.info("WebSocket 连接关闭: sessionId={}, closeStatus={}", sessionId, status);
|
|
|
}
|
|
|
@@ -235,6 +367,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
|
|
String sessionId = session.getId();
|
|
|
SESSION_MAP.remove(sessionId);
|
|
|
+ clearAuthentication(session);
|
|
|
subscriptionManager.removeSession(sessionId);
|
|
|
log.error("WebSocket 传输异常: sessionId={}", sessionId, exception);
|
|
|
}
|
|
|
@@ -253,7 +386,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
if (msg != null) {
|
|
|
response.put("msg", msg);
|
|
|
}
|
|
|
- session.sendMessage(new TextMessage(response.toJSONString()));
|
|
|
+ sendText(session, response.toJSONString());
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -264,6 +397,38 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
|
|
|
response.put("type", "error");
|
|
|
response.put("success", false);
|
|
|
response.put("msg", msg);
|
|
|
- session.sendMessage(new TextMessage(response.toJSONString()));
|
|
|
+ sendText(session, response.toJSONString());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 统一串行发送,防止心跳、Redis 通知和工单定时推送并发写同一会话。
|
|
|
+ */
|
|
|
+ public boolean sendText(WebSocketSession session, String payload) {
|
|
|
+ if (session == null || !session.isOpen()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ synchronized (session) {
|
|
|
+ session.sendMessage(new TextMessage(payload));
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("WebSocket 消息发送失败: sessionId={}", session.getId(), e);
|
|
|
+ SESSION_MAP.remove(session.getId());
|
|
|
+ subscriptionManager.removeSession(session.getId());
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void clearAuthentication(WebSocketSession session) {
|
|
|
+ if (session != null) {
|
|
|
+ Map<String, Object> attributes = session.getAttributes();
|
|
|
+ if (attributes != null) {
|
|
|
+ synchronized (session) {
|
|
|
+ attributes.remove(WORK_ORDER_LOGIN_USER_ATTRIBUTE);
|
|
|
+ attributes.remove(WORK_ORDER_TOKEN_ATTRIBUTE);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
-}
|
|
|
+}
|