Browse Source

netty model

lihao16 9 months ago
parent
commit
1ac173f6c7
26 changed files with 915 additions and 7 deletions
  1. 7 0
      smsb-admin/pom.xml
  2. 1 0
      smsb-modules/pom.xml
  3. 5 0
      smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/SmsbDevice.java
  4. 5 0
      smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/bo/SmsbDeviceBo.java
  5. 5 0
      smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/vo/SmsbDeviceVo.java
  6. 1 1
      smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbDeviceServiceImpl.java
  7. 1 1
      smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbProductServiceImpl.java
  8. 1 1
      smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbProductTypeServiceImpl.java
  9. 2 0
      smsb-modules/smsb-netty/.gitattributes
  10. 33 0
      smsb-modules/smsb-netty/.gitignore
  11. 19 0
      smsb-modules/smsb-netty/.mvn/wrapper/maven-wrapper.properties
  12. 63 0
      smsb-modules/smsb-netty/pom.xml
  13. 50 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/ClientHandler.java
  14. 58 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/NettyClientController.java
  15. 97 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/ReusableNettyClient.java
  16. 163 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/handler/ConnectServerHandler.java
  17. 105 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/handler/HeartServerHandler.java
  18. 16 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/push/PushMessage.java
  19. 26 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/push/PushMessageType.java
  20. 17 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/receive/ReceiveMessage.java
  21. 27 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/receive/ReceiveMessageType.java
  22. 73 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/server/NettyServer.java
  23. 39 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/util/NettyConstants.java
  24. 82 0
      smsb-modules/smsb-netty/src/main/java/com/inspur/netty/util/PushMsgUtil.java
  25. 5 0
      smsb-plus-ui/src/api/smsb/device/device_type.ts
  26. 14 4
      smsb-plus-ui/src/views/smsb/device/index.vue

+ 7 - 0
smsb-admin/pom.xml

@@ -92,6 +92,13 @@
             <artifactId>smsb-device</artifactId>
             <version>${revision}</version>
         </dependency>
+
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-netty</artifactId>
+            <version>${revision}</version>
+        </dependency>
+
         <!--  工作流模块  -->
         <dependency>
             <groupId>com.inspur</groupId>

+ 1 - 0
smsb-modules/pom.xml

@@ -16,6 +16,7 @@
         <module>smsb-system</module>
         <module>smsb-workflow</module>
         <module>smsb-device</module>
+        <module>smsb-netty</module>
     </modules>
 
     <artifactId>smsb-modules</artifactId>

+ 5 - 0
smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/SmsbDevice.java

@@ -115,6 +115,11 @@ public class SmsbDevice extends TenantEntity {
      */
     private Date lastOnline;
 
+    /**
+     * 离线时间
+     */
+    private Date offlineTime;
+
     /**
      * 内网ip
      */

+ 5 - 0
smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/bo/SmsbDeviceBo.java

@@ -120,6 +120,11 @@ public class SmsbDeviceBo extends BaseEntity {
      */
     private Date lastOnline;
 
+    /**
+     * 离线时间
+     */
+    private Date offlineTime;
+
     /**
      * 内网ip
      */

+ 5 - 0
smsb-modules/smsb-device/src/main/java/com/inspur/device/domain/vo/SmsbDeviceVo.java

@@ -140,6 +140,11 @@ public class SmsbDeviceVo implements Serializable {
     @ExcelProperty(value = "上次在线时间")
     private Date lastOnline;
 
+    /**
+     * 离线时间
+     */
+    private Date offlineTime;
+
     /**
      * 内网ip
      */

+ 1 - 1
smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbDeviceServiceImpl.java

@@ -187,7 +187,7 @@ public class SmsbDeviceServiceImpl implements ISmsbDeviceService {
     @Override
     public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
         if(isValid){
-            //TODO 做一些业务上的校验,判断是否需要校验
+
         }
         return baseMapper.deleteByIds(ids) > 0;
     }

+ 1 - 1
smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbProductServiceImpl.java

@@ -178,7 +178,7 @@ public class SmsbProductServiceImpl implements ISmsbProductService {
     @CacheEvict(value = "msr:product:product", allEntries = true)
     public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
         if(isValid){
-            //TODO 做一些业务上的校验,判断是否需要校验
+
         }
         return baseMapper.deleteByIds(ids) > 0;
     }

+ 1 - 1
smsb-modules/smsb-device/src/main/java/com/inspur/device/service/impl/SmsbProductTypeServiceImpl.java

@@ -140,7 +140,7 @@ public class SmsbProductTypeServiceImpl implements ISmsbProductTypeService {
     @CacheEvict(cacheNames = "msr:product:productType", allEntries = true)
     public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
         if(isValid){
-            //TODO 做一些业务上的校验,判断是否需要校验
+
         }
         return baseMapper.deleteByIds(ids) > 0;
     }

+ 2 - 0
smsb-modules/smsb-netty/.gitattributes

@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf

+ 33 - 0
smsb-modules/smsb-netty/.gitignore

@@ -0,0 +1,33 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/

+ 19 - 0
smsb-modules/smsb-netty/.mvn/wrapper/maven-wrapper.properties

@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

+ 63 - 0
smsb-modules/smsb-netty/pom.xml

@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <groupId>com.inspur</groupId>
+        <artifactId>smsb-modules</artifactId>
+        <version>${revision}</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>smsb-netty</artifactId>
+
+    <description>
+        netty 模块
+    </description>
+
+    <dependencies>
+        <!-- netty -->
+        <dependency>
+            <groupId>io.netty</groupId>
+            <artifactId>netty-all</artifactId>
+            <version>4.1.15.Final</version>
+            <scope>compile</scope>
+        </dependency>
+
+        <!-- 通用工具-->
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-common-core</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-common-redis</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-common-log</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-common-encrypt</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.inspur</groupId>
+            <artifactId>smsb-device</artifactId>
+            <version>${revision}</version>
+        </dependency>
+
+        <!-- 阿里JSON解析器 -->
+        <dependency>
+            <groupId>com.alibaba.fastjson2</groupId>
+            <artifactId>fastjson2</artifactId>
+            <version>2.0.4</version>
+        </dependency>
+
+    </dependencies>
+
+
+</project>

+ 50 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/ClientHandler.java

@@ -0,0 +1,50 @@
+package com.inspur.netty.client;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.timeout.IdleStateHandler;
+
+/**
+ * 客户端处理器
+ * @author lihao16
+ */
+public class ClientHandler extends SimpleChannelInboundHandler<String> {
+
+    private final ReusableNettyClient client;
+
+    public ClientHandler(ReusableNettyClient client) {
+        this.client = client;
+    }
+
+    @Override
+    public void channelActive(ChannelHandlerContext ctx) {
+        System.out.println("通道激活,客户端已准备就绪");
+    }
+
+    @Override
+    public void channelRead0(ChannelHandlerContext ctx, String msg) {
+        System.out.println("收到服务器消息: " + msg);
+    }
+
+    @Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        if (evt instanceof IdleStateHandler) {
+            System.out.println("发送心跳消息: HEARTBEAT");
+            ctx.writeAndFlush("HEARTBEAT");
+        } else {
+            super.userEventTriggered(ctx, evt);
+        }
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+        System.err.println("出现异常: " + cause.getMessage());
+        ctx.close();
+    }
+
+    @Override
+    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+        System.out.println("连接断开,尝试重连...");
+        client.start(); // 重新启动客户端
+    }
+}

+ 58 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/NettyClientController.java

@@ -0,0 +1,58 @@
+package com.inspur.netty.client;
+
+import cn.dev33.satoken.annotation.SaIgnore;
+import com.inspur.netty.message.push.PushMessage;
+import com.inspur.netty.message.push.PushMessageType;
+import com.inspur.netty.util.PushMsgUtil;
+import org.dromara.common.core.domain.R;
+import org.springframework.web.bind.annotation.*;
+
+
+/**
+ * netty client test controller
+ * @author lihao16
+ */
+@RestController()
+@RequestMapping("/netty")
+public class NettyClientController {
+
+    private static ReusableNettyClient nettyClient = new ReusableNettyClient("127.0.0.1", 8900);
+
+    @SaIgnore
+    @GetMapping("/init")
+    public R<Void> edit() {
+        // 创建一个netty client,并发送心跳保持
+        nettyClient.start();
+        return R.ok();
+    }
+
+    @SaIgnore
+    @GetMapping("/sendMac/{mac}")
+    public R<Void> sendMac(@PathVariable String mac)  {
+        String msg = "{\n" +
+            "    \"messageType\" : 1,\n" +
+            "    \"messageData\" : \"" + mac + "\"\n" +
+            "}";
+        nettyClient.sendMessage(msg);
+        return R.ok();
+    }
+
+    @SaIgnore
+    @GetMapping("/stop")
+    public R<Void> stop() {
+        nettyClient.stop();
+        return R.ok();
+    }
+
+    @SaIgnore
+    @GetMapping("/server/push/{mac}")
+    public R<Boolean> serverPush(@PathVariable String mac) {
+        PushMessage pushMessage = new PushMessage();
+        pushMessage.setMessageType(PushMessageType.HEAT_DATA.getValue());
+        pushMessage.setMessageData("hello!");
+        boolean result = PushMsgUtil.send(mac,pushMessage);
+        return R.ok(result);
+    }
+
+
+}

+ 97 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/client/ReusableNettyClient.java

@@ -0,0 +1,97 @@
+package com.inspur.netty.client;
+
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.*;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.nio.NioSocketChannel;
+import io.netty.handler.timeout.IdleStateHandler;
+import io.netty.handler.codec.string.StringDecoder;
+import io.netty.handler.codec.string.StringEncoder;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * netty client
+ * @author lihao16
+ */
+public class ReusableNettyClient {
+
+    private final String host;
+    private final int port;
+    private Channel channel;
+    private final EventLoopGroup group;
+
+    public ReusableNettyClient(String host, int port) {
+        this.host = host;
+        this.port = port;
+        this.group = new NioEventLoopGroup();
+    }
+
+    /**
+     * 启动客户端并连接到服务器
+     */
+    public void start() {
+        Bootstrap bootstrap = new Bootstrap();
+        bootstrap.group(group)
+            .channel(NioSocketChannel.class)
+            .option(ChannelOption.SO_KEEPALIVE, true)
+            .handler(new ChannelInitializer<Channel>() {
+                @Override
+                protected void initChannel(Channel ch) {
+                    ChannelPipeline pipeline = ch.pipeline();
+                    pipeline.addLast(new IdleStateHandler(0, 5, 0, TimeUnit.SECONDS)); // 每5秒发送心跳
+                    pipeline.addLast(new StringDecoder());
+                    pipeline.addLast(new StringEncoder());
+                    pipeline.addLast(new ClientHandler(ReusableNettyClient.this)); // 传入客户端实例
+                }
+            });
+
+        connect(bootstrap);
+    }
+
+    /**
+     * 连接到服务器
+     */
+    private void connect(Bootstrap bootstrap) {
+        try {
+            ChannelFuture future = bootstrap.connect(host, port).sync();
+            this.channel = future.channel();
+            System.out.println("成功连接到服务器: " + host + ":" + port);
+        } catch (InterruptedException e) {
+            System.err.println("连接失败,尝试重连...");
+            retryConnect(bootstrap);
+        }
+    }
+
+    /**
+     * 断线重连
+     */
+    private void retryConnect(Bootstrap bootstrap) {
+        // 5秒后重连
+        group.schedule(() -> connect(bootstrap), 5, TimeUnit.SECONDS);
+    }
+
+    /**
+     * 发送消息到服务器
+     */
+    public void sendMessage(String message) {
+        if (channel != null && channel.isActive()) {
+            channel.writeAndFlush(message);
+        } else {
+            System.err.println("无法发送消息,通道未激活");
+        }
+    }
+
+    /**
+     * 停止客户端
+     */
+    public void stop() {
+        if (channel != null) {
+            channel.close();
+        }
+        group.shutdownGracefully();
+        System.out.println("客户端已关闭");
+    }
+}
+
+

+ 163 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/handler/ConnectServerHandler.java

@@ -0,0 +1,163 @@
+package com.inspur.netty.handler;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.inspur.device.domain.SmsbDevice;
+import com.inspur.device.domain.vo.SmsbDeviceVo;
+import com.inspur.device.mapper.SmsbDeviceMapper;
+import com.inspur.netty.message.receive.ReceiveMessage;
+import com.inspur.netty.message.receive.ReceiveMessageType;
+import com.inspur.netty.util.NettyConstants;
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.SpringUtils;
+import org.dromara.common.core.utils.StringUtils;
+
+import java.util.Date;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * netty connect handler
+ *
+ * @author lihao16
+ */
+@Slf4j
+public class ConnectServerHandler extends ChannelInboundHandlerAdapter {
+    /**
+     * mac-channel map
+     */
+    private static Map<String, Channel> macChannelMap = new ConcurrentHashMap();
+
+    /**
+     * channelId-mac map
+     */
+    private static Map<String, String> channelIdMacMap = new ConcurrentHashMap<>();
+
+    /**
+     * device mapper
+     */
+    private static final SmsbDeviceMapper smsbDeviceMapper = SpringUtils.getBean(SmsbDeviceMapper.class);
+
+    /**
+     * 当客户端连接服务器完成就会触发该方法
+     */
+    @Override
+    public void channelActive(ChannelHandlerContext ctx) throws Exception {
+        log.info("ConnectServerHandler: channelId = " + ctx.channel().id() + ",login channelGroup");
+    }
+
+    @Override
+    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+        ByteBuf buf = (ByteBuf) msg;
+        byte[] buffer = new byte[buf.readableBytes()];
+        buf.readBytes(buffer);
+        String message = new String(buffer, "utf-8");
+        if (StringUtils.isEmpty(message)) {
+            log.info("ConnectServerHandler: 客户端发来的消息是空");
+            return;
+        }
+        String[] listMsg = message.split(NettyConstants.DATA_PACK_SEPARATOR);
+        for (String obj : listMsg) {
+            if (StringUtils.isEmpty(obj)) {
+                continue;
+            }
+            ReceiveMessage receiveMessage = JSONObject.parseObject(obj, ReceiveMessage.class);
+            if (receiveMessage.getMessageType().equals(ReceiveMessageType.MAC_DATA.getValue())) {
+                log.info("ConnectServerHandler: 接收到客户端发来MAC消息:" + message);
+                String channelId = ctx.channel().id().toString();
+                String macAddr = receiveMessage.getMessageData().toString().toLowerCase(Locale.ROOT);
+                log.info("ConnectServerHandler: channelId = " + channelId + ",macAddr =" + macAddr);
+                // bugfix 存在相同mac多个ChannelId
+                Channel oldChannel = macChannelMap.get(macAddr);
+                macChannelMap.put(macAddr, ctx.channel());
+                if (null != oldChannel) {
+                    channelIdMacMap.remove(oldChannel.id().toString());
+                }
+                channelIdMacMap.putIfAbsent(channelId, macAddr);
+                // 验证设备的合法性
+                if (validateDevice(macAddr, ctx)) {
+                    // update device online status
+                    updateDeviceOnlineStatue(macAddr, NettyConstants.DEVICE_ONLINE_STATUS);
+                }
+            } else {
+                ctx.fireChannelRead(obj);
+            }
+        }
+    }
+
+    private boolean validateDevice(String macAddr, ChannelHandlerContext ctx) {
+        // 根据Mac地址查询设备是否在平台录入
+        SmsbDeviceVo smsbDeviceVo = smsbDeviceMapper.selectVoOne(new LambdaQueryWrapper<SmsbDevice>()
+            .eq(SmsbDevice::getMac, macAddr));
+        if (null == smsbDeviceVo) {
+            log.info("ConnectServerHandler: device not in smsb plus,macAddr = " + macAddr);
+            // 关闭该长连接
+            ctx.close();
+            return false;
+        }
+        return true;
+    }
+
+    public static void updateDeviceOnlineStatue(String macAddr, Integer onlineStatus) {
+        log.info("ConnectServerHandler: update device mac : " + macAddr + " online status to : " + onlineStatus);
+        try {
+            // 设备上线
+            if (onlineStatus.equals(NettyConstants.DEVICE_ONLINE_STATUS)) {
+                smsbDeviceMapper.update(null, new LambdaUpdateWrapper<SmsbDevice>()
+                    .eq(SmsbDevice::getMac, macAddr)
+                    .set(SmsbDevice::getOnlineStatus, onlineStatus)
+                    .set(SmsbDevice::getLastOnline, new Date()));
+            }
+            // 设备离线
+            smsbDeviceMapper.update(null, new LambdaUpdateWrapper<SmsbDevice>()
+                .eq(SmsbDevice::getMac, macAddr)
+                .set(SmsbDevice::getOnlineStatus, onlineStatus)
+                .set(SmsbDevice::getOfflineTime, new Date()));
+        } catch (Exception e) {
+            log.error("ConnectServerHandler: update remote device status error {}", e.getMessage());
+        }
+
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
+        log.info("ConnectServerHandler:Server,exceptionCaught.ctx.close");
+        cause.printStackTrace();
+        removeChannel(ctx);
+        ctx.close();
+    }
+
+    @Override
+    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+        log.info("ConnectServerHandler:Server,channelInactive channel id = " + ctx.channel().id().toString());
+        removeChannel(ctx);
+        ctx.close();
+    }
+
+    private void removeChannel(ChannelHandlerContext ctx) {
+        log.info("ConnectServerHandler: Server,removeChannel ctx.channel().id() = " + ctx.channel().id().toString());
+        String channelId = ctx.channel().id().toString();
+        if (channelIdMacMap.containsKey(channelId)) {
+            String macAddr = channelIdMacMap.get(channelId);
+            channelIdMacMap.remove(channelId);
+            if (macChannelMap.containsKey(macAddr)) {
+                macChannelMap.remove(macAddr);
+                updateDeviceOnlineStatue(macAddr, NettyConstants.DEVICE_OFFLINE_STATUS);
+            }
+        }
+    }
+
+    public static Map<String, Channel> getMacChannelMap() {
+        return macChannelMap;
+    }
+
+    public static Map<String, String> getChannelIdMacMap() {
+        return channelIdMacMap;
+    }
+}

+ 105 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/handler/HeartServerHandler.java

@@ -0,0 +1,105 @@
+package com.inspur.netty.handler;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.inspur.netty.message.push.PushMessage;
+import com.inspur.netty.message.push.PushMessageType;
+import com.inspur.netty.message.receive.ReceiveMessage;
+import com.inspur.netty.message.receive.ReceiveMessageType;
+import com.inspur.netty.util.NettyConstants;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.timeout.IdleState;
+import io.netty.handler.timeout.IdleStateEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.redis.utils.RedisUtils;
+
+import java.nio.charset.Charset;
+import java.util.Map;
+
+/**
+ * netty heart handler
+ *
+ * @author lihao16
+ */
+@Slf4j
+public class HeartServerHandler extends ChannelInboundHandlerAdapter {
+
+    @Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        if (evt instanceof IdleStateEvent) {
+            IdleStateEvent event = (IdleStateEvent) evt;
+            if (event.state() == IdleState.READER_IDLE) {
+                try {
+                    String key = NettyConstants.DEVICE_HEART_LOSS_PREFIX + ctx.channel().id();
+                    // Redis 原子递增
+                    Long lossHeartCount = RedisUtils.incrAtomicValue(key);
+                    if (lossHeartCount >= NettyConstants.HEART_LOSS_COUNT) {
+                        log.info("HeartServerHandler: lossHeartCount = " + lossHeartCount + ", close channel" + ctx.channel().id());
+                        // 清理 Redis 计数
+                        RedisUtils.deleteObject(key);
+                        ctx.channel().close();
+                    } else {
+                        log.info("HeartServerHandler: lossHeartCount = " + lossHeartCount + ", channel id = " + ctx.channel().id());
+                    }
+                } catch (Exception e) {
+                    log.error("Redis 操作失败: " + e.getMessage(), e);
+                }
+            }
+        } else {
+            super.userEventTriggered(ctx, evt);
+        }
+    }
+    @Override
+    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+        String message = (String) msg;
+        ReceiveMessage receiveMessage = JSONObject.parseObject(message, ReceiveMessage.class);
+        String macAddr = receiveMessage.getMessageData().toString();
+        if (receiveMessage.getMessageType().equals(ReceiveMessageType.HEAT_DATA.getValue())) {
+            if (!StringUtils.isEmpty(macAddr)) {
+                Map<String, Channel> macChannelIdMap = ConnectServerHandler.getMacChannelMap();
+                // 判断macChannelIdMap是否包含macAddr这个key值
+                if (!macChannelIdMap.containsKey(macAddr)) {
+                    log.info("心跳监测:macChannelMap 无macAddr=" + macAddr + "信息,重新保存");
+                    macChannelIdMap.put(macAddr, ctx.channel());
+                    ConnectServerHandler.updateDeviceOnlineStatue(macAddr, NettyConstants.DEVICE_ONLINE_STATUS);
+                }
+            }
+            PushMessage pushMessage = new PushMessage();
+            pushMessage.setMessageType(PushMessageType.HEAT_DATA.getValue());
+            String messagePush = JSON.toJSONString(pushMessage);
+            ByteBuf byteBuf = Unpooled.copiedBuffer(messagePush + NettyConstants.DATA_PACK_SEPARATOR, Charset.forName("utf-8"));
+            ctx.channel().writeAndFlush(byteBuf);
+            log.debug("HeartServerHandler:心跳消息已返回:" + ctx.channel().id() + "macAddr = " + macAddr);
+            // redis cache device last heart time
+            RedisUtils.setCacheObject(NettyConstants.DEVICE_LAST_HEART_PREFIX + macAddr, System.currentTimeMillis());
+        }
+    }
+    /*@Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        log.info("HeartServerHandler: " + ctx.channel().id() + " : 已经一分钟未收到客户端的消息!");
+        if (evt instanceof IdleStateEvent){
+            IdleStateEvent event = (IdleStateEvent)evt;
+            if (event.state() == IdleState.READER_IDLE){
+                String lossHeartCount = RedisUtils.getCacheObject(NettyConstants.DEVICE_HEART_LOSS_PREFIX + ctx.channel().id());
+                if (StringUtils.isNotEmpty(lossHeartCount) && Integer.parseInt(lossHeartCount) > NettyConstants.HEART_LOSS_COUNT){
+                    log.info("HeartServerHandler : lossHeartCount = " + lossHeartCount + ",channel close");
+                    ctx.channel().close();
+                    return;
+                }
+                if (StringUtils.isEmpty(lossHeartCount)) {
+                    RedisUtils.setCacheObject(NettyConstants.DEVICE_HEART_LOSS_PREFIX + ctx.channel().id(), "1");
+                }else {
+                    RedisUtils.setCacheObject(NettyConstants.DEVICE_HEART_LOSS_PREFIX + ctx.channel().id(), String.valueOf(Integer.parseInt(lossHeartCount) + 1));
+                }
+            }
+        }else {
+            super.userEventTriggered(ctx,evt);
+        }
+    }*/
+
+}

+ 16 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/push/PushMessage.java

@@ -0,0 +1,16 @@
+package com.inspur.netty.message.push;
+
+import lombok.Data;
+
+/**
+ * 消息推送数据结构
+ * @author lihao16
+ */
+@Data
+public class PushMessage<T> {
+
+    private String messageType;
+
+    private T messageData;
+
+}

+ 26 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/push/PushMessageType.java

@@ -0,0 +1,26 @@
+package com.inspur.netty.message.push;
+
+/**
+ * 消息类型
+ * @author lihao16
+ */
+public enum PushMessageType {
+
+    /**
+     * 心跳保持
+     * */
+    HEAT_DATA("2");
+
+    private String value;
+
+    private PushMessageType(String value)
+    {
+        this.value = value;
+    }
+
+    public String getValue()
+    {
+        return value;
+    }
+
+}

+ 17 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/receive/ReceiveMessage.java

@@ -0,0 +1,17 @@
+package com.inspur.netty.message.receive;
+
+
+import lombok.Data;
+
+/**
+ * 消息接收数据
+ * @author lihao16
+ */
+@Data
+public class ReceiveMessage<T> {
+
+    private String messageType;
+
+    private T messageData;
+
+}

+ 27 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/message/receive/ReceiveMessageType.java

@@ -0,0 +1,27 @@
+package com.inspur.netty.message.receive;
+
+public enum ReceiveMessageType {
+
+    /**
+     * 传输mac地址
+     * */
+    MAC_DATA("1"),
+
+    /**
+     * 心跳保持
+     * */
+    HEAT_DATA("2");
+
+    private String value;
+
+    private ReceiveMessageType(String value)
+    {
+        this.value = value;
+    }
+
+    public String getValue()
+    {
+        return value;
+    }
+}
+

+ 73 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/server/NettyServer.java

@@ -0,0 +1,73 @@
+package com.inspur.netty.server;
+
+import com.inspur.netty.handler.ConnectServerHandler;
+import com.inspur.netty.handler.HeartServerHandler;
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.timeout.IdleStateHandler;
+import lombok.SneakyThrows;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * netty server init
+ *
+ * @author lihao16
+ */
+@Component
+public class NettyServer {
+
+    private static final Logger log = LoggerFactory.getLogger(NettyServer.class);
+
+    @Bean
+    public void startNettyServer(){
+        new Thread(new Runnable() {
+            @SneakyThrows
+            @Override
+            public void run() {
+                initNettyServer();
+            }
+        }).start();
+    }
+
+    public void initNettyServer() throws InterruptedException {
+        EventLoopGroup bossGroup = new NioEventLoopGroup();
+        EventLoopGroup workerGroup = new NioEventLoopGroup();
+        try {
+            ServerBootstrap b = new ServerBootstrap();
+            b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class)
+                    // 指定连接队列大小
+                    .option(ChannelOption.SO_BACKLOG, 128)
+                    //KeepAlive
+                    .childOption(ChannelOption.SO_KEEPALIVE, true)
+                    //Handler
+                    .childHandler(new ChannelInitializer<SocketChannel>() {
+                        @Override
+                        protected void initChannel(SocketChannel channel) throws Exception {
+                            channel.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
+                            channel.pipeline().addLast(new ConnectServerHandler());
+                            channel.pipeline().addLast(new HeartServerHandler());
+                        }
+                    });
+            ChannelFuture f = b.bind(8900).sync();
+            if (f.isSuccess()) {
+                log.info("Server,启动Netty服务端成功,端口号:" + 8900);
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            //workerGroup.shutdownGracefully();
+            //bossGroup.shutdownGracefully();
+        }
+    }
+}

+ 39 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/util/NettyConstants.java

@@ -0,0 +1,39 @@
+package com.inspur.netty.util;
+
+/**
+ * Netty 常量
+ * @author lihao16
+ */
+public class NettyConstants {
+
+    /**
+     * 设备上线
+     */
+    public static final Integer DEVICE_ONLINE_STATUS = 1;
+
+    /**
+     * 设备离线
+     */
+    public static final Integer DEVICE_OFFLINE_STATUS = 2;
+
+    /**
+     * 数据分隔符
+     */
+    public static final String DATA_PACK_SEPARATOR = "####";
+
+    /**
+     * 心跳超时次数
+     */
+    public static final int HEART_LOSS_COUNT = 3;
+
+    /**
+     * redis netty 心跳超时次数key
+     */
+    public static final String DEVICE_HEART_LOSS_PREFIX = "device:heart:loss:";
+
+    /**
+     * redis netty client last heartbeat key
+     */
+    public static final String DEVICE_LAST_HEART_PREFIX = "device:heart:last:";
+
+}

+ 82 - 0
smsb-modules/smsb-netty/src/main/java/com/inspur/netty/util/PushMsgUtil.java

@@ -0,0 +1,82 @@
+package com.inspur.netty.util;
+
+import com.alibaba.fastjson2.JSON;
+import com.inspur.netty.handler.ConnectServerHandler;
+import com.inspur.netty.message.push.PushMessage;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFutureListener;
+import lombok.extern.slf4j.Slf4j;
+
+import java.nio.charset.Charset;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Consumer;
+
+/**
+ * netty 推送消息工具类
+ * @author lihao16
+ */
+@Slf4j
+public class PushMsgUtil {
+
+    public static boolean send(String macAddr, PushMessage pushMessage) {
+        CompletableFuture<Boolean> future = new CompletableFuture<>();
+        sendAsync(macAddr, pushMessage, future::complete);
+        try {
+            // 同步等待结果返回
+            return future.get();
+        } catch (InterruptedException | ExecutionException e) {
+            log.error("PushMsgUtil : send method encountered an exception", e);
+            Thread.currentThread().interrupt(); // 恢复中断状态
+            return false;
+        }
+    }
+
+
+    private static void sendAsync(String macAddr, PushMessage pushMessage, Consumer<Boolean> callback) {
+        Map<String, Channel> macChannelIdMap = ConnectServerHandler.getMacChannelMap();
+        if (!macChannelIdMap.containsKey(macAddr)) {
+            log.info("PushMsgUtil : macChannelIdMap not contains macAddr:" + macAddr);
+            callback.accept(false);
+            return;
+        }
+        Channel channel = macChannelIdMap.get(macAddr);
+        String messagePush = JSON.toJSONString(pushMessage);
+        log.info("PushMsgUtil : pushMessage to Client message : " + messagePush);
+        ByteBuf byteBuf = Unpooled.copiedBuffer(messagePush + NettyConstants.DATA_PACK_SEPARATOR, Charset.forName("utf-8"));
+        channel.writeAndFlush(byteBuf).addListener((ChannelFutureListener) future -> {
+            if (future.isSuccess()) {
+                log.info("PushMsgUtil : message successfully sent to client for macAddr: " + macAddr);
+                // 成功回调
+                callback.accept(true);
+            } else {
+                log.error("PushMsgUtil : failed to send message to client for macAddr: " + macAddr, future.cause());
+                // 失败回调
+                callback.accept(false);
+            }
+        });
+    }
+
+
+
+
+
+    /*private static boolean send(String macAddr, PushMessage pushMessage){
+        Map<String, Channel> macChannelIdMap = ConnectServerHandler.getMacChannelMap();
+        // 判断macChannelIdMap是否包含macAddr这个key值
+        if (!macChannelIdMap.containsKey(macAddr)) {
+            log.info("PushMsgUtil : macChannelIdMap not contains macAddr:" + macAddr);
+            return false;
+        }
+        Channel channel = macChannelIdMap.get(macAddr);
+        String messagePush = JSON.toJSONString(pushMessage);
+        log.info("PushMsgUtil : pushMessage to Client message : " + messagePush);
+        ByteBuf byteBuf = Unpooled.copiedBuffer(messagePush + NettyConstants.DATA_PACK_SEPARATOR, Charset.forName("utf-8"));
+        channel.writeAndFlush(byteBuf);
+        return true;
+    }*/
+
+}

+ 5 - 0
smsb-plus-ui/src/api/smsb/device/device_type.ts

@@ -99,6 +99,11 @@ export interface DeviceVO {
    */
   lastOnline: string;
 
+  /**
+   * 上次离线时间
+   */
+  offlineTime: string;
+
   /**
    * 内网ip
    */

+ 14 - 4
smsb-plus-ui/src/views/smsb/device/index.vue

@@ -4,13 +4,13 @@
       <div v-show="showSearch" class="mb-[10px]">
         <el-card shadow="hover">
           <el-form ref="queryFormRef" :model="queryParams" :inline="true">
-            <el-form-item label="设备名称" prop="name">
+            <el-form-item label="名称" prop="name">
               <el-input v-model="queryParams.name" placeholder="请输入设备名称" clearable @keyup.enter="handleQuery" />
             </el-form-item>
-            <el-form-item label="设备SN" prop="serialNumber">
+            <el-form-item label="SN" prop="serialNumber">
               <el-input v-model="queryParams.serialNumber" placeholder="请输入设备SN" clearable @keyup.enter="handleQuery" />
             </el-form-item>
-            <el-form-item label="设备MAC" prop="mac">
+            <el-form-item label="MAC" prop="mac">
               <el-input v-model="queryParams.mac" placeholder="请输入设备MAC" clearable @keyup.enter="handleQuery" />
             </el-form-item>
             <el-form-item>
@@ -44,7 +44,7 @@
       <el-table v-loading="loading" :data="deviceList" @selection-change="handleSelectionChange">
         <el-table-column type="selection" width="55" align="center" />
 <!--        <el-table-column label="id" align="center" prop="id" v-if="true" />-->
-        <el-table-column label="设备名称" align="left" prop="name" :show-overflow-tooltip="true"/>
+        <el-table-column label="设备名称" align="left" prop="name" width="300" :show-overflow-tooltip="true"/>
         <el-table-column label="屏幕配置" align="left" prop="productName" width="100" :show-overflow-tooltip="true"/>
         <el-table-column label="设备型号" align="left" prop="deviceModel" width="80" :show-overflow-tooltip="true"/>
         <el-table-column label="设备SN" align="left" prop="serialNumber" width="150" :show-overflow-tooltip="true"/>
@@ -56,6 +56,16 @@
             <dict-tag :options="sys_device_online" :value="scope.row.onlineStatus" />
           </template>
         </el-table-column>
+        <el-table-column label="上线时间" align="left" prop="lastOnline" width="160">
+          <template #default="scope">
+            <span>{{ parseTime(scope.row.lastOnline, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="上次离线" align="left" prop="offlineTime" width="160">
+          <template #default="scope">
+            <span>{{ parseTime(scope.row.offlineTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
+          </template>
+        </el-table-column>
 <!--        <el-table-column label="是否激活,0未激活,1已激活,2已初始化" align="center" prop="activate" />-->
         <el-table-column label="具体地址" align="left" prop="address" width="180" :show-overflow-tooltip="true"/>
         <el-table-column label="创建时间" align="left" prop="createTime" width="160">