소스 검색

1、WebSocket建立与维护
2、登录鉴权、报税口信息下发

liweimin 2 주 전
부모
커밋
5fbffa8f00

+ 14 - 0
ruoyi-device/src/main/java/com/ruoyi/device/domain/model/TsbUserDeviceBind.java

@@ -25,6 +25,9 @@ public class TsbUserDeviceBind extends TsbUserDevice
     /** 设备SN码 */
     private Long deviceSn;
 
+    /** 设备类型 */
+    private String deviceType;
+
     public TsbUserDeviceBind()
     {
     }
@@ -69,6 +72,16 @@ public class TsbUserDeviceBind extends TsbUserDevice
         this.deviceSn = deviceSn;
     }
 
+    public String getDeviceType()
+    {
+        return deviceType;
+    }
+
+    public void setDeviceType(String deviceType)
+    {
+        this.deviceType = deviceType;
+    }
+
     @Override
     public String toString()
     {
@@ -79,6 +92,7 @@ public class TsbUserDeviceBind extends TsbUserDevice
                 .append("deviceId", getDeviceId())
                 .append("imei", getImei())
                 .append("deviceSn", getDeviceSn())
+                .append("deviceType", getDeviceType())
                 .append("bindTime", getBindTime())
                 .toString();
     }

+ 8 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mapper/TsbUserDeviceMapper.java

@@ -32,6 +32,14 @@ public interface TsbUserDeviceMapper
     TsbUserDeviceBind selectBindByDeviceId(Long deviceId);
 
     /**
+     * 查询指定用户当前绑定
+     *
+     * @param userId 用户ID
+     * @return 绑定关系
+     */
+    TsbUserDeviceBind selectBindByUserId(Long userId);
+
+    /**
      * 未绑定任何调试宝设备的用户列表(用于下拉)
      *
      * @return 用户列表

+ 96 - 0
ruoyi-device/src/main/java/com/ruoyi/device/mqtt/domain/encoder/TaxDataDown.java

@@ -0,0 +1,96 @@
+package com.ruoyi.device.mqtt.domain.encoder;
+
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+
+/**
+ * 报税口下行数据
+ *
+ * @author lwm
+ */
+public class TaxDataDown extends BaseJsonBody
+{
+
+    /** 按钮操作 */
+    private String buttonType;
+
+    /** 接口号 */
+    private String interfaceNo;
+
+    /** 枪号 */
+    private Integer gunNo;
+
+    /** 是否启用新国标(1:启用,0:禁用) */
+    private Integer newNationalStandard;
+
+    /** 新国标报税口 */
+    private Integer newNationalStandardTaxNo;
+
+    /** 日期:年-月-日 */
+    private String queryDate;
+
+    public TaxDataDown()
+    {
+        super();
+    }
+
+    public String getButtonType()
+    {
+        return buttonType;
+    }
+
+    public void setButtonType(String buttonType)
+    {
+        this.buttonType = buttonType;
+    }
+
+    public String getInterfaceNo()
+    {
+        return interfaceNo;
+    }
+
+    public void setInterfaceNo(String interfaceNo)
+    {
+        this.interfaceNo = interfaceNo;
+    }
+
+    public Integer getGunNo()
+    {
+        return gunNo;
+    }
+
+    public void setGunNo(Integer gunNo)
+    {
+        this.gunNo = gunNo;
+    }
+
+    public Integer getNewNationalStandard()
+    {
+        return newNationalStandard;
+    }
+
+    public void setNewNationalStandard(Integer newNationalStandard)
+    {
+        this.newNationalStandard = newNationalStandard;
+    }
+
+    public Integer getNewNationalStandardTaxNo()
+    {
+        return newNationalStandardTaxNo;
+    }
+
+    public void setNewNationalStandardTaxNo(Integer newNationalStandardTaxNo)
+    {
+        this.newNationalStandardTaxNo = newNationalStandardTaxNo;
+    }
+
+    public String getQueryDate()
+    {
+        return queryDate;
+    }
+
+    public void setQueryDate(String queryDate)
+    {
+        this.queryDate = queryDate;
+    }
+
+}

+ 22 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbSpringWebSocketConfigurator.java

@@ -0,0 +1,22 @@
+package com.ruoyi.device.websocket;
+
+import com.ruoyi.common.utils.spring.SpringUtils;
+import jakarta.websocket.server.ServerEndpointConfig;
+import org.springframework.stereotype.Component;
+
+/**
+ * 使 {@link jakarta.websocket.server.ServerEndpoint} 支持 Spring Bean 注入
+ * <p>
+ * 须与 {@link TsbWebSocketServer} 位于同一模块,避免 @ServerEndpoint configurator 跨模块类加载失败
+ *
+ * @author lwm
+ */
+@Component
+public class TsbSpringWebSocketConfigurator extends ServerEndpointConfig.Configurator
+{
+    @Override
+    public <T> T getEndpointInstance(Class<T> clazz) throws InstantiationException
+    {
+        return SpringUtils.getBean(clazz);
+    }
+}

+ 48 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketConstants.java

@@ -0,0 +1,48 @@
+package com.ruoyi.device.websocket;
+
+/**
+ * 调试宝 WebSocket 常量
+ *
+ * @author lwm
+ */
+public final class TsbWebSocketConstants
+{
+
+    /**
+     * 默认最多允许同时在线人数100
+     */
+    public static int SOCKET_MAX_ONLINE_COUNT = 10;
+
+    /**
+     * 获取 token 参数名称
+     */
+    public static final String TOKEN_PARAM_NAME = "token";
+
+    /**
+     * 绑定信息属性名称
+     */
+    public static final String USER_DEVICE_BIND_KEY = "tsbUserDeviceBind";
+
+    /**
+     * 绑定信息属性名称
+     */
+    public static final String USER_PERMISSIONS_KEY = "userPermissions";
+
+    /**
+     * 绑定信息属性名称
+     */
+    public static final String DEVICE_SN_KEY = "deviceSn";
+
+    /**
+     * code 1:成功,0:失败
+     */
+    public static final int SUCCESS_CODE = 1;
+    public static final int FAIL_CODE = 0;
+
+    /** 功能标识:websocket 连接 */
+    public static final String CONNECT = "connect";
+
+    private TsbWebSocketConstants()
+    {
+    }
+}

+ 137 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketServer.java

@@ -0,0 +1,137 @@
+package com.ruoyi.device.websocket;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.websocket.model.TsbWebSocketMessage;
+import com.ruoyi.framework.websocket.SemaphoreUtils;
+import com.ruoyi.framework.websocket.WebSocketUtils;
+import jakarta.websocket.*;
+import jakarta.websocket.server.ServerEndpoint;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.concurrent.Semaphore;
+
+/**
+ * 调试宝 Web 端 WebSocket:/websocket/tsb?token=xxx 发送鉴权登录令牌
+ * <p>
+ * 连接标识:deviceSn
+ *
+ * @author lwm
+ */
+@Component
+@ServerEndpoint(value = "/websocket/tsb", configurator = TsbSpringWebSocketConfigurator.class)
+public class TsbWebSocketServer
+{
+    /**
+     * TsbWebSocketServer 日志控制器
+     */
+    private static final Logger log = LoggerFactory.getLogger(TsbWebSocketServer.class);
+
+    /**
+     * 信号量,用于控制同时在线人数
+     */
+    private static Semaphore socketSemaphore = new Semaphore(TsbWebSocketConstants.SOCKET_MAX_ONLINE_COUNT);
+
+    private final TsbWebSocketService tsbWebSocketService;
+
+    public TsbWebSocketServer(TsbWebSocketService tsbWebSocketService)
+    {
+        this.tsbWebSocketService = tsbWebSocketService;
+    }
+
+    /**
+     * 连接建立成功调用的方法
+     */
+    @OnOpen
+    public void onOpen(Session session) throws IOException {
+        boolean semaphoreFlag = false;
+        // 尝试获取信号量
+        semaphoreFlag = SemaphoreUtils.tryAcquire(socketSemaphore);
+        if (!semaphoreFlag)
+        {
+            // 未获取到信号量
+            log.error("\n 当前在线人数超过限制数- {}", TsbWebSocketConstants.SOCKET_MAX_ONLINE_COUNT);
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(TsbWebSocketConstants.CONNECT, "当前在线人数超过限制数:" + TsbWebSocketConstants.SOCKET_MAX_ONLINE_COUNT));
+            WebSocketUtils.closeQuietly(session, CloseReason.CloseCodes.TRY_AGAIN_LATER, "超过连接数限制");
+            return;
+        }
+        // 用户鉴权
+        String token = WebSocketUtils.resolveQueryParam(session, TsbWebSocketConstants.TOKEN_PARAM_NAME);
+        JSONObject bind = tsbWebSocketService.authenticate(token, session);
+        if (bind == null)
+        {
+            log.error("鉴权失败或未绑定设备");
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(TsbWebSocketConstants.CONNECT, "鉴权失败或未绑定设备"));
+            WebSocketUtils.closeQuietly(session, CloseReason.CloseCodes.CANNOT_ACCEPT, "鉴权失败或未绑定设备");
+            SemaphoreUtils.release(socketSemaphore);
+            return;
+        }
+        log.info("当前连接人数:{}", TsbWebSocketUsers.getSessionUsers().size());
+        TsbWebSocketUsers.sendMessageToUserByText(session,
+                TsbWebSocketMessage.success(TsbWebSocketConstants.CONNECT, "连接成功", bind));
+    }
+
+    /**
+     * 连接关闭时处理
+     */
+    @OnClose
+    public void onClose(Session session)
+    {
+        Long deviceSn = tsbWebSocketService.getDeviceSnFromSession(session);
+        if (deviceSn != null)
+        {
+            log.info("WebSocket 连接关闭, deviceSn={}", deviceSn);
+            // 移除用户
+            boolean removeFlag = TsbWebSocketUsers.remove(deviceSn);
+            if (!removeFlag)
+            {
+                // 获取到信号量则需释放
+                SemaphoreUtils.release(socketSemaphore);
+            }
+        }
+        else
+        {
+            log.info("WebSocket 连接关闭");
+        }
+    }
+
+    /**
+     * 抛出异常时处理
+     */
+    @OnError
+    public void onError(Session session, Throwable error) throws Exception
+    {
+        log.warn("WebSocket 异常, sessionId={}, err={}", session.getId(), error.getMessage());
+        if (session.isOpen())
+        {
+            // 关闭连接
+            session.close();
+        }
+        Long deviceSn = tsbWebSocketService.getDeviceSnFromSession(session);
+        if (deviceSn != null)
+        {
+            log.info("WebSocket 连接异常, deviceSn={}", deviceSn);
+            // 移除用户
+            TsbWebSocketUsers.remove(deviceSn);
+        }
+        else
+        {
+            log.info("WebSocket 连接异常");
+        }
+        // 获取到信号量则需释放
+        SemaphoreUtils.release(socketSemaphore);
+    }
+
+    /**
+     * 服务器接收到客户端消息时调用的方法
+     */
+    @OnMessage
+    public void onMessage(String message, Session session)
+    {
+        tsbWebSocketService.pushDeviceSyncFromPage(session, message);
+    }
+}

+ 200 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketService.java

@@ -0,0 +1,200 @@
+package com.ruoyi.device.websocket;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.common.core.domain.model.LoginUser;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.device.domain.model.TsbUserDeviceBind;
+import com.ruoyi.device.mapper.TsbUserDeviceMapper;
+import com.ruoyi.device.mqtt.domain.BaseJsonBody;
+import com.ruoyi.device.mqtt.domain.encoder.TaxDataDown;
+import com.ruoyi.device.mqtt.enums.CmdTypeEnum;
+import com.ruoyi.device.mqtt.enums.MsgTypeEnum;
+import com.ruoyi.device.mqtt.handler.DeviceOnlineManager;
+import com.ruoyi.device.mqtt.handler.HandlerManager;
+import com.ruoyi.device.mqtt.handler.encoder.IEncoder;
+import com.ruoyi.device.mqtt.util.MsgHandlerUtil;
+import com.ruoyi.device.websocket.model.TsbWebSocketMessage;
+import com.ruoyi.framework.web.service.TokenService;
+import jakarta.annotation.Resource;
+import jakarta.websocket.Session;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 调试宝 WebSocket 业务:鉴权、会话注册、页面数据转发
+ *
+ * @author lwm
+ */
+@Service
+public class TsbWebSocketService
+{
+    private static final Logger log = LoggerFactory.getLogger(TsbWebSocketService.class);
+
+    @Resource
+    private TokenService tokenService;
+
+    @Resource
+    private TsbUserDeviceMapper tsbUserDeviceMapper;
+
+    @Resource
+    private HandlerManager handlerManager;
+
+    @Resource
+    private DeviceOnlineManager deviceOnlineManager;
+
+    /**
+     * 握手鉴权:解析 token、校验用户已绑定设备,写入 session 属性
+     *
+     * @return 绑定信息;失败返回 null
+     */
+    public JSONObject authenticate(String token, Session session)
+    {
+        LoginUser loginUser = tokenService.getLoginUser(token);
+        if (loginUser == null || loginUser.getUser() == null)
+        {
+            log.warn("WebSocket 鉴权失败:无效 token");
+            return null;
+        }
+        Long userId = loginUser.getUserId();
+        TsbUserDeviceBind bind = tsbUserDeviceMapper.selectBindByUserId(userId);
+        if (bind == null || bind.getDeviceSn() == null)
+        {
+            log.warn("WebSocket 鉴权失败:用户未绑定设备, userId={}", userId);
+            return null;
+        }
+        // session中写入用户设备信息 便于后续获取
+        session.getUserProperties().put(TsbWebSocketConstants.DEVICE_SN_KEY, bind.getDeviceSn());
+        session.getUserProperties().put(TsbWebSocketConstants.USER_DEVICE_BIND_KEY, bind);
+        session.getUserProperties().put(TsbWebSocketConstants.USER_PERMISSIONS_KEY, loginUser.getPermissions());
+        // 创建信息map
+        TsbWebSocketUsers.put(bind.getDeviceSn(), session);
+        log.info("WebSocket 鉴权成功, userId={}, deviceSn={}", userId, bind.getDeviceSn());
+        return JSONObject.from(bind);
+    }
+
+
+    /**
+     * 处理 Web端 -> 后端消息 -> 设备 MQTT
+     */
+    public void pushDeviceSyncFromPage(Session session, String message)
+    {
+        if (StringUtils.isEmpty(message))
+        {
+            return;
+        }
+        // 1、解析消息
+        TsbWebSocketMessage tsbWebSocketMessage = JSON.parseObject(message, TsbWebSocketMessage.class);
+        if (tsbWebSocketMessage == null || StringUtils.isEmpty(tsbWebSocketMessage.getCmdType()))
+        {
+            log.warn("WebSocket 消息格式无效, sessionId={}, message={}", session.getId(), message);
+            return;
+        }
+        // 2、组装下发所需的消息
+        TsbUserDeviceBind bind = getBindFromSession(session);
+        if (bind == null || bind.getDeviceSn() == null || StringUtils.isEmpty(bind.getDeviceType())) {
+            log.warn("WebSocket 获取用户设备信息失败, sessionId={}, bind={}", session.getId(), bind);
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(tsbWebSocketMessage.getCmdType(), "会话缺少设备绑定信息,请重新连接"));
+            return;
+        }
+        Set<String> userPermissions = getUserPermissionsFromSession(session);
+        if (StringUtils.isEmpty(userPermissions) || !userPermissions.contains(tsbWebSocketMessage.getCmdType()))
+        {
+            log.warn("WebSocket 用户权限不足, sessionId={}, cmdType={}", session.getId(), tsbWebSocketMessage.getCmdType());
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(tsbWebSocketMessage.getCmdType(), "用户权限不足"));
+            return;
+        }
+        CmdTypeEnum cmdType = CmdTypeEnum.resolveDownlink(tsbWebSocketMessage.getCmdType());
+        if (cmdType == null)
+        {
+            log.warn("WebSocket 命令类型无效, sessionId={}, cmdType={}", session.getId(), tsbWebSocketMessage.getCmdType());
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(tsbWebSocketMessage.getCmdType(), "命令类型无效"));
+            return;
+        }
+        if (!deviceOnlineManager.isOnline(bind.getDeviceSn()))
+        {
+            log.warn("WebSocket 设备未在线, sessionId={}, deviceSn={}", session.getId(), bind.getDeviceSn());
+            TsbWebSocketUsers.sendMessageToUserByText(session,
+                    TsbWebSocketMessage.fail(tsbWebSocketMessage.getCmdType(), "设备未在线"));
+            return;
+        }
+
+        // 3、发送消息
+        TaxDataDown down = tsbWebSocketMessage.getData() == null ?
+                new TaxDataDown() : tsbWebSocketMessage.getData().toJavaObject(TaxDataDown.class);
+        down.setDeviceType(bind.getDeviceType());
+        down.setDeviceSn(bind.getDeviceSn());
+        down.setCmdType(cmdType.getCmdDownType());
+        log.info("Web端 -> 设备 MQTT 同步消息, userId={}, deviceSn={}", bind.getUserId(), bind.getDeviceSn());
+        String key = MsgHandlerUtil.getEncoderKey(MsgTypeEnum.JSON_BODY);
+        IEncoder<BaseJsonBody> encoder = (IEncoder<BaseJsonBody>) handlerManager.getEncoder(key);
+        if (encoder == null)
+        {
+            log.warn("未找到 JSON Body 编码器, deviceSn={}", bind.getDeviceSn());
+            return;
+        }
+        log.info("MQTT 下行发送, cmdType={}, deviceType={}, deviceSn={}",
+                cmdType.getCmdType(), bind.getDeviceType(), bind.getDeviceSn());
+        encoder.encode(down);
+    }
+
+    /**
+     * 从 Session 中安全获取绑定信息
+     */
+    public TsbUserDeviceBind getBindFromSession(Session session) {
+        Object bindObj = session.getUserProperties().get(TsbWebSocketConstants.USER_DEVICE_BIND_KEY);
+        return bindObj instanceof TsbUserDeviceBind ? (TsbUserDeviceBind) bindObj : null;
+    }
+
+    /**
+     * 从 Session 中安全获取用户权限集合
+     */
+    @SuppressWarnings("unchecked")
+    public Set<String> getUserPermissionsFromSession(Session session) {
+        Object permissionsObj = session.getUserProperties().get(TsbWebSocketConstants.USER_PERMISSIONS_KEY);
+        return permissionsObj instanceof Set ? (Set<String>) permissionsObj : null;
+    }
+
+    /**
+     * 从 Session 中安全获取 deviceSn
+     */
+    public Long getDeviceSnFromSession(Session session) {
+        Object deviceSnObj = session.getUserProperties().get(TsbWebSocketConstants.DEVICE_SN_KEY);
+        return deviceSnObj instanceof Long ? (Long) deviceSnObj : null;
+    }
+
+    /**
+     * 设备 MQTT 上行解析后推送到 Web端
+     */
+    public void pushPageSyncFromDevice(String pageKey, JSONObject bodyObj)
+    {
+        // 1、通过 deviceSn 判断设备是否在线
+        Long deviceSn = bodyObj.getLong("deviceSn");
+        if (deviceSn == null)
+        {
+            log.warn("MQTT上行设备SN码无效");
+            return;
+        }
+        if (!deviceOnlineManager.isOnline(deviceSn))
+        {
+            log.warn("MQTT上行设备未在线, deviceSn={}", deviceSn);
+            return;
+        }
+        // 2、通过 deviceSn 判断WebSocket是否连接
+        Map<Long, Session> sessionUsers = TsbWebSocketUsers.getSessionUsers();
+        if (!sessionUsers.containsKey(deviceSn)) {
+            log.warn("web端用户未登录, deviceSn={}", deviceSn);
+            return;
+        }
+        // 3、发送消息
+        TsbWebSocketMessage msg = TsbWebSocketMessage.success(pageKey, "操作成功", bodyObj);
+        TsbWebSocketUsers.sendMessageToUserByText(sessionUsers.get(deviceSn), msg);
+    }
+}

+ 104 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/TsbWebSocketUsers.java

@@ -0,0 +1,104 @@
+package com.ruoyi.device.websocket;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONWriter;
+import com.ruoyi.device.websocket.model.TsbWebSocketMessage;
+import jakarta.websocket.Session;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 调试宝 websocket 客户端用户集
+ * 
+ * @author lwm
+ */
+public class TsbWebSocketUsers
+{
+    /**
+     * TsbWebSocketUsers 日志控制器
+     */
+    private static final Logger LOGGER = LoggerFactory.getLogger(TsbWebSocketUsers.class);
+
+    /**
+     * deviceSn -> session用户集
+     */
+    private static final Map<Long, Session> SESSION_USERS = new ConcurrentHashMap<>();
+
+    /**
+     * 注册会话;同一 deviceSn 仅保留最新连接
+     *
+     * @param key 唯一键 设备sn码
+     * @param session session用户信息
+     */
+    public static void put(Long key, Session session)
+    {
+        Session oldSession = SESSION_USERS.put(key, session);
+        // 假如旧会话存在,且不是当前会话(此时相当于被新连接替换了,还是同一个设备,所以不需要释放锁)
+        if (oldSession != null && oldSession.isOpen() && !oldSession.getId().equals(session.getId()))
+        {
+            LOGGER.info("设备编号={}, 旧会话={}, 被新会话={}替换", key, oldSession.getId(), session.getId());
+        }
+        LOGGER.info("WebSocket注册会话, deviceSn={}, sessionId={}", key, session.getId());
+    }
+
+    /**
+     * 移出 session用户信息
+     *
+     * @param key 键 设备sn码
+     */
+    public static boolean remove(Long key)
+    {
+        LOGGER.info("正在移出session用户信息, deviceSn={}", key);
+        Session remove = SESSION_USERS.remove(key);
+        if (remove != null)
+        {
+            boolean containsValue = SESSION_USERS.containsValue(remove);
+            LOGGER.info("SESSION_USERS移出结果 - {}", containsValue ? "失败" : "成功");
+            return containsValue;
+        }
+        else
+        {
+            return true;
+        }
+    }
+
+    /**
+     * 获取在线 session用户集
+     *
+     * @return 返回用户集合
+     */
+    public static Map<Long, Session> getSessionUsers()
+    {
+        return SESSION_USERS;
+    }
+
+    /**
+     * 已有session会话 按照文本消息发送 msg统一格式消息
+     *
+     * @param session session会话
+     * @param msg 消息内容
+     */
+    public static void sendMessageToUserByText(Session session, TsbWebSocketMessage msg)
+    {
+        if (session != null && session.isOpen())
+        {
+            try
+            {
+                session.getBasicRemote().sendText(JSON.toJSONString(msg, JSONWriter.Feature.WriteNulls));
+            }
+            catch (IOException e)
+            {
+                LOGGER.error("session={}, [发送消息异常]", session.getId(), e);
+            }
+        }
+        else
+        {
+            LOGGER.info("session={}, [已离线,无法发送消息]", session != null ? session.getId() : "null");
+        }
+    }
+
+}

+ 97 - 0
ruoyi-device/src/main/java/com/ruoyi/device/websocket/model/TsbWebSocketMessage.java

@@ -0,0 +1,97 @@
+package com.ruoyi.device.websocket.model;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.websocket.TsbWebSocketConstants;
+
+import java.io.Serializable;
+
+/**
+ * WebSocket 消息体
+ *
+ * @author lwm
+ */
+public class TsbWebSocketMessage implements Serializable
+{
+    private static final long serialVersionUID = 1L;
+
+    /** 页面/功能标识,如 common:tax */
+    private String cmdType;
+
+    /** 是否成功,1:成功,0:失败 */
+    private Integer code;
+
+    /** 提示信息 */
+    private String message;
+
+    /** 业务数据 */
+    private JSONObject data;
+
+    public TsbWebSocketMessage()
+    {
+    }
+
+    public TsbWebSocketMessage(String cmdType, Integer code, String message, JSONObject data)
+    {
+        this.cmdType = cmdType;
+        this.code = code;
+        this.message = message;
+        this.data = data;
+    }
+
+    /**
+     * 失败应答
+     */
+    public static TsbWebSocketMessage fail(String cmdType, String message)
+    {
+        return new TsbWebSocketMessage(cmdType, TsbWebSocketConstants.FAIL_CODE, message, null);
+    }
+
+    /**
+     * 成功应答
+     */
+    public static TsbWebSocketMessage success(String cmdType, String message, JSONObject data)
+    {
+        return new TsbWebSocketMessage(cmdType, TsbWebSocketConstants.SUCCESS_CODE, message, data);
+    }
+
+    public String getCmdType()
+    {
+        return cmdType;
+    }
+
+    public void setCmdType(String cmdType)
+    {
+        this.cmdType = cmdType;
+    }
+
+    public Integer getCode()
+    {
+        return code;
+    }
+
+    public void setCode(Integer code)
+    {
+        this.code = code;
+    }
+
+    public String getMessage()
+    {
+        return message;
+    }
+
+    public void setMessage(String message)
+    {
+        this.message = message;
+    }
+
+    public JSONObject getData()
+    {
+        return data;
+    }
+
+    public void setData(JSONObject data)
+    {
+        this.data = data;
+    }
+
+}

+ 18 - 0
ruoyi-device/src/main/resources/mapper/device/TsbUserDeviceMapper.xml

@@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                 bud.device_id as deviceId,
                 d.imei as imei,
                 d.device_sn as deviceSn,
+                d.device_type as deviceType,
                 bud.bind_time as bindTime
         from tsb_user_device bud
                 left join sys_user u on u.user_id = bud.user_id and u.del_flag = '0'
@@ -51,6 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
                bud.device_id as deviceId,
                d.imei as imei,
                d.device_sn as deviceSn,
+               d.device_type as deviceType,
                bud.bind_time as bindTime
         from tsb_user_device bud
                  left join sys_user u on u.user_id = bud.user_id and u.del_flag = '0'
@@ -59,6 +61,22 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by bud.user_id desc limit 1
     </select>
 
+    <select id="selectBindByUserId" parameterType="Long" resultType="TsbUserDeviceBind">
+        select bud.user_id as userId,
+               u.user_name as userName,
+               u.nick_name as nickName,
+               bud.device_id as deviceId,
+               d.imei as imei,
+               d.device_sn as deviceSn,
+               d.device_type as deviceType,
+               bud.bind_time as bindTime
+        from tsb_user_device bud
+                 left join sys_user u on u.user_id = bud.user_id and u.del_flag = '0'
+                 left join tsb_device d on d.device_id = bud.device_id and d.del_flag = '0'
+        where bud.user_id = #{userId}
+        order by bud.user_id desc limit 1
+    </select>
+
     <select id="selectUnbindUsers" resultType="SysUser">
         select u.user_id   as userId,
                u.user_name as userName,

+ 1 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java

@@ -101,6 +101,7 @@ public class SecurityConfig
                 permitAllUrl.getUrls().forEach(url -> requests.requestMatchers(url).permitAll());
                 // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                 requests.requestMatchers("/login", "/register", "/captchaImage").permitAll()
+                    .requestMatchers("/websocket/tsb/**", "/websocket/message/**").permitAll()
                     // 静态资源,可匿名访问
                     .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js", "/profile/**").permitAll()
                     .requestMatchers("/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**", "/druid/**").permitAll()

+ 31 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java

@@ -84,6 +84,37 @@ public class TokenService
     }
 
     /**
+     * 根据 JWT 字符串获取登录用户(WebSocket 等无法携带 Header 的场景)
+     *
+     * @param token JWT 或 Bearer 前缀令牌
+     * @return 登录用户,无效时返回 null
+     */
+    public LoginUser getLoginUser(String token)
+    {
+        if (StringUtils.isEmpty(token))
+        {
+            return null;
+        }
+        try
+        {
+            if (token.startsWith(Constants.TOKEN_PREFIX))
+            {
+                token = token.replace(Constants.TOKEN_PREFIX, "");
+            }
+            Claims claims = parseToken(token);
+            // 解析对应的权限以及用户信息
+            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
+            String userKey = getTokenKey(uuid);
+            return redisCache.getCacheObject(userKey);
+        }
+        catch (Exception e)
+        {
+            log.error("根据令牌获取用户信息异常'{}'", e.getMessage());
+        }
+        return null;
+    }
+
+    /**
      * 设置用户身份信息
      */
     public void setLoginUser(LoginUser loginUser)

+ 58 - 0
ruoyi-framework/src/main/java/com/ruoyi/framework/websocket/WebSocketUtils.java

@@ -0,0 +1,58 @@
+package com.ruoyi.framework.websocket;
+
+import jakarta.websocket.CloseReason;
+import jakarta.websocket.Session;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * WebSocket 通用工具
+ *
+ * @author lwm
+ */
+public final class WebSocketUtils
+{
+    private static final Logger log = LoggerFactory.getLogger(WebSocketUtils.class);
+
+    private WebSocketUtils()
+    {
+    }
+
+    /**
+     * 读取 URL 查询参数(取第一个值)
+     */
+    public static String resolveQueryParam(Session session, String name)
+    {
+        Map<String, List<String>> params = session.getRequestParameterMap();
+        if (params == null || !params.containsKey(name))
+        {
+            return null;
+        }
+        List<String> values = params.get(name);
+        return values == null || values.isEmpty() ? null : values.get(0);
+    }
+
+    /**
+     * 关闭连接
+     */
+    public static void closeQuietly(Session session, CloseReason.CloseCode code, String reason)
+    {
+        if (session == null || !session.isOpen())
+        {
+            return;
+        }
+        try
+        {
+            session.close(new CloseReason(code, reason));
+        }
+        catch (IOException e)
+        {
+            log.debug("关闭 WebSocket 失败: {}", e.getMessage());
+        }
+    }
+
+}