简介智能家居远程控制系统的Java设计源码包适合Java开发者和物联网爱好者用于解决家居设备远程控制、环境监控与安全防护等实际问题。压缩包共247个文件、2.68MB以62个Java源文件为核心配合XML配置文件来管理组件布局与数据绑定借助PNG/JPG图片优化界面展示通过Gradle脚本完成项目的构建、测试与打包整体结构清晰。已有330人学习下载。源码覆盖灯光、窗帘、温度等多类家居场景的交互逻辑直观展示远程开启或关闭电器、调节环境参数等典型功能同时针对互联网远程控制场景对网络通信稳定性、数据传输安全性以及异常处理进行了设计考虑。尤其适合需要完成课程设计或入门物联网开发的读者通过研读完整项目可掌握从界面编写到设备通信再到打包部署的完整流程并学习如何构建便于功能扩展与后期维护的智能家居系统。1. 先把这套 Java 智能家居远程控制系统的边界画清楚适合谁、解决什么问题在 App 上点一下“开灯”命令要穿过公网、路由器、设备固件最后让继电器吸合这条链路里任何一环慢了、断了、重复了用户在界面上看到的就是“控制失败”。基于 Java 的智能家居远程控制系统核心不是写一个 REST 接口而是把设备接入、在线状态、指令下发、回执确认这几件事用工程化的方式串起来。对正在做 Java 课程设计、毕业设计或者想给自己手头的智能硬件做一个统一远程入口的开发者来说这套源码最大的价值是提供了一个能真实跑通“控制端→服务端→设备端→回执”的最小完整闭环而不是一摞没法启动的类文件。我们先把系统边界定义清楚设备端不是手机而是灯、插座、温湿度传感器这类资源受限的联网模块服务端负责接入、鉴权、路由控制端是 App 或 Web 页面。下面从设计、编码、联调到踩坑按一条能复现的路径讲完。2. 系统骨架设计Java Spring Boot Netty 实现设备接入层的选型与报文协议2.1 三端分工设备、服务器、控制端各管哪一段链路智能家居远程控制系统的第一件事是先理解“谁连谁”。很多第一次做的人会把架构画成 App 直连设备这在局域网里可行一旦设备在家、人在公司App 找不到设备的私网 IP方案就废了。常见的做法是让设备主动外连公网服务器维持一条长连接App 也只连服务器不直接碰设备。这样设备不需要公网 IP也不需要路由器上做端口映射只要设备能访问外网服务器就能找到它。三端职责这样切设备端只做两件事采集状态和执行指令服务端维护设备会话、校验权限、把控制指令路由到对应设备并等待设备回执控制端负责展示设备列表和下发操作请求。硬件端如果用 STM32、Arduino 或者树莓派做设备侧语言通常是 C 或 Python但服务端换成 Java 之后整个后台的并发能力、运维生态和团队协作成本会明显不同。以温度上报为例设备定时把{type:REPORT,deviceId:sensor-01,ts:1710000000,payload:{temp:26.5}}发给服务端服务端更新内存和数据库里的温度值控制端查询时拿到的是服务端缓存的最新数据。用户在 App 上点击“开空调”走的是反向流程控制端调服务端接口服务端找到 sensor/空调对应的 Channel把指令报文发给设备设备执行完回一条 ACK。2.2 技术选型Spring Boot、Netty、Redis 在系统里各自承担什么角色给这套系统选型要按“接入层、业务层、存储层”三个维度考虑。接入层承载的是海量长连接普通 Tomcat 线程模型不适合几万台设备同时在线所以设备接入用 Netty它基于 NIO 事件循环一个线程能撑住大量空闲连接心跳、断线重连、编解码这些都有现成组件。业务层用 Spring Boot负责把控制端的 HTTP 请求转换成内部指令顺便解决参数校验、依赖注入、配置管理这些琐碎事。存储层的角色分配要分两类数据设备状态这类高频读写数据放 Redis在线状态、最新温湿度都走缓存指令记录、设备信息这类审计型数据落 MySQL 或 H2。如果只想在课程设计场景跑通Redis 可以用 ConcurrentHashMap 缓替代H2 当数据库等联调通过再替换成正式中间件替换点要提前在接口层隔离开避免后面返工。这里要说明为什么不选纯 Python 方案。基于树莓派的智能家居系统常见做法是设备侧 Python 服务端 Flask开发速度快但设备规模上来之后Python 的 GIL 和多进程模型在处理长连接网关时不如 Netty 顺手而且后台要接用户体系、告警、消息推送时Java 那一套 Spring 家族生态明显更全。如果你的项目同时还要被拿去当 Java 面试项目讲服务端用 Java 写聊设备会话、线程池、消息幂等都比聊 Flask 脚本有内容。2.3 报文格式一条“开灯”指令从 App 到设备的字段设计报文协议是设备和服务端之间的“普通话”。用 JSON 文本协议在开发期最好调试解析成本对智能家居这种小报文完全可接受。定义双向统一的字段结构服务端下发指令和设备上报状态都复用这套格式。字段类型必填说明msgIdString是全局唯一消息 ID用于回执匹配和幂等去重typeString是消息类型REGISTER / REPORT / COMMAND / ACK / HEARTBEATdeviceIdString是设备唯一标识如light-001tslong是发送方时间戳毫秒级payloadObject取决于类型指令参数或上报数据如{on:true}codeintACK 时必填执行结果0 成功非 0 失败为什么必须带 msgId设备端在弱网环境收到指令后执行了但回执丢了服务端超时重发如果设备没有按 msgId 去重灯会被执行两次开操作。另外注册消息和设备上线的顺序也有讲究服务端必须先处理 REGISTER 把 Channel 和设备 ID 绑定之后该设备的 REPORT 才被信任。2.4 源码目录怎么切controller、service、netty、device 四层包结构拿到一套源码先看包结构包结构合理后续改功能才能不迷路。下面这套结构是我在类似项目里常用的也是这套源码的主干src/main/java/com/smarthome/ ├── controller/ # 面向控制端的 REST 接口 │ └── DeviceController.java ├── service/ # 业务逻辑指令路由、状态聚合、离线补偿 │ ├── CommandService.java │ └── DeviceStateService.java ├── netty/ # 设备接入网关 │ ├── DeviceServer.java │ ├── DeviceMessageHandler.java │ └── CodecInitializer.java ├── device/ # 设备模拟器没有硬件时用它联调 │ └── DeviceSimulator.java ├── common/ # 常量、报文对象、工具类 │ ├── MessageType.java │ └── DeviceMessage.java └── repository/ # 数据访问层 └── CommandRepository.java目录切分的依据是按“连接来源”分controller 管人、netty 管设备两者在 service 层汇合。device 模拟器单独放是因为它本质上是测试工具不能混进生产代码。包依赖方向必须单向controller 和 netty 都依赖 serviceservice 不反向依赖 netty。否则一个设备上下线事件业务逻辑直接去操纵 Channel后面做离线指令补偿时会把代码改乱。3. 跑通服务端Netty 设备网关的最小可运行代码与会话管理3.1 起步依赖pom.xml 里只需要引入这两个组件先建一个标准 Spring Boot 工程Java 版本按你自己本地环境来8 或 11 都能跑通。pom 里核心依赖只需要 Spring Boot Web 和 Netty其余按需加parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version你自己的 Spring Boot 2.x 版本/version relativePath/ /parent dependencies !-- 控制端 REST 接口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 设备接入网关 -- dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependency !-- JSON 解析 -- dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version2.0.32/version /dependency /dependencies这里没引入 redis 和 mysql原因是第一版要尽快跑通链路状态内存放指令先打日志验证完再补持久化。Netty 版本固定 4.1.x不要用 4.0很多编解码器的包名和行为在 4.1 里调整过照着老博客写容易在类加载阶段翻车。Fastjson 换成 Jackson 也完全没问题只要把 handler 里的解析逻辑对应换掉即可。3.2 设备接入网关启动类Netty ServerBootstrap 的关键配置Netty 的设备接入层要单独起一个端口不要和 Spring Boot 的 8080 混在一起。下面这个启动类就是网关的入口生产环境一般由一个 Spring ApplicationRunner 在工程启动后自动调用start()方法Component public class DeviceServer { private final int port 7123; private final DeviceMessageHandler deviceMessageHandler; public DeviceServer(DeviceMessageHandler deviceMessageHandler) { this.deviceMessageHandler deviceMessageHandler; } public void start() throws InterruptedException { EventLoopGroup boss new NioEventLoopGroup(1); EventLoopGroup worker new NioEventLoopGroup(); try { ServerBootstrap b new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { // 按行拆包设备端每条报文以 \n 结尾 ch.pipeline().addLast(new LineBasedFrameDecoder(8192)); ch.pipeline().addLast(new StringDecoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(deviceMessageHandler); } }); b.bind(port).sync().channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }几个参数要专门说明。LineBasedFrameDecoder(8192)是按换行符拆包单条报文最大 8KB超过会报帧过大异常设备端上报的数据不可能超过这个值。TCP_NODELAY必须开否则指令下发可能被 Nagle 算法滞留 40ms控制类指令对延迟敏感。SO_KEEPALIVE只是 TCP 层探活不能代替应用层心跳。Handler 注入了 Spring 管理的deviceMessageHandler因为 Netty 的 channel 初始化回调发生在 Netty 线程里而 Spring 管理的 Bean 有依赖注入这个 Handler 必须标成 Spring 组件不能每来一个连接就 new 一个。3.3 收到设备消息后别在 Netty 线程里做业务handler 与线程池的写法设备消息 Handler 是整个接入层最容易写错的地方。Netty 的 channelRead 回调运行在 IO 线程上如果在里面直接查数据库、写文件、做耗时的业务校验会阻塞这个线程上所有其他连接的消息处理。正确姿势是把业务逻辑丢进独立线程池让 IO 线程立刻返回Component public class DeviceMessageHandler extends ChannelInboundHandlerAdapter { private final MapString, Channel deviceChannels new ConcurrentHashMap(); private final ExecutorService bizExecutor new ThreadPoolExecutor(8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1024), new ThreadPoolExecutor.CallerRunsPolicy()); Override public void channelRead(ChannelHandlerContext ctx, Object msg) { final String raw (String) msg; bizExecutor.execute(() - { try { DeviceMessage message JSON.parseObject(raw, DeviceMessage.class); String type message.getType(); String deviceId message.getDeviceId(); if (MessageType.REGISTER.equals(type)) { deviceChannels.put(deviceId, ctx.channel()); sendAck(ctx.channel(), message, 0, OK); } else if (MessageType.REPORT.equals(type)) { System.out.println(设备上报: deviceId - message.getPayload()); } else if (MessageType.ACK.equals(type)) { System.out.println(设备回执: message.getMsgId() code message.getCode()); } } catch (Exception e) { e.printStackTrace(); } }); } public Channel getChannel(String deviceId) { return deviceChannels.get(deviceId); } private void sendAck(Channel channel, DeviceMessage req, int code, String msg) { DeviceMessage ack new DeviceMessage(); ack.setMsgId(req.getMsgId()); ack.setType(MessageType.ACK); ack.setDeviceId(req.getDeviceId()); ack.setCode(code); ack.setPayload(msg); channel.writeAndFlush(JSON.toJSONString(ack) \n); } }线程池参数按场景调核心线程 8 够支撑上千台设备的低频上报队列 1024 是缓冲设备上报突发时先排队CallerRunsPolicy是队列满了之后由调用线程执行宁可让 IO 线程慢一点也不能丢消息。注意deviceChannels是静态还是实例变量的问题——Handler 是 Spring 单例所有设备连接共用同一个 Handler 实例所以这个 Map 必须是线程安全的 ConcurrentHashMap不能用 HashMap。设备端把ctx.channel()传进 lambda 里用也安全Channel 在 Netty 里是线程安全的对象writeAndFlush 可以从任意线程调用。3.4 设备在线表ConcurrentHashMap 管理 Channel 时的删除竞态设备会话表是“设备ID → Channel”的映射但这个映射的增删有两个隐蔽问题。第一个是重连覆盖设备断线后立刻重连新连接注册时把旧 Channel 覆盖掉这没问题问题在于旧连接在覆盖之后才触发 channelInactive如果处理逻辑是deviceChannels.remove(deviceId, oldChannel)带 value 的 remove 方法可以避免误删新连接。如果直接remove(deviceId)设备刚好重连完成新连接会被旧连接的事件给删掉设备就变成“在线但找不到”。第二个是 Channel 关闭事件里做清理。在 Handler 里重写 channelInactive当设备连接断开时把对应的映射关系移除Override public void channelInactive(ChannelHandlerContext ctx) { // 只在 value 相等时删除防止误删重连后的新 Channel deviceChannels.entrySet().removeIf(e - e.getValue() ctx.channel()); super.channelInactive(ctx); }这里必须用引用相等不能用equals。Netty 的 Channel 实现类没有重写 equals默认就是引用比较但保险起见过滤条件应该直接拿 Channel 对象比。另一个踩坑点ChannelInactive 不一定在业务线程池里不要在这个回调里做锁操作或者长时间数据库访问把清理动作本身做得越轻越好。4. 没有硬件也能联调Java 设备模拟器与端到端控制链路验证4.1 设备模拟器用 Java 代码模拟心跳、温湿度上报和控制回执课程设计阶段大概率没有真实硬件设备模拟器是这套源码里联调的关键部分。模拟器做三件事连接服务端、定时发心跳和上报、收到 COMMAND 指令后打印并回 ACK。核心就是一个 Bootstrap 客户端public class DeviceSimulator { private final String host 127.0.0.1; private final int port 7123; private final String deviceId light-001; private Channel channel; private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); public void connect() throws InterruptedException { NioEventLoopGroup group new NioEventLoopGroup(1); Bootstrap b new Bootstrap(); b.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new LineBasedFrameDecoder(8192)); ch.pipeline().addLast(new StringDecoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new SimpleChannelInboundHandlerString() { Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { handleServerMessage(msg); } }); } }); this.channel b.connect(host, port).sync().channel(); register(); startHeartbeat(); } private void register() { DeviceMessage reg new DeviceMessage(); reg.setMsgId(UUID.randomUUID().toString()); reg.setType(MessageType.REGISTER); reg.setDeviceId(deviceId); reg.setTs(System.currentTimeMillis()); channel.writeAndFlush(JSON.toJSONString(reg) \n); } private void startHeartbeat() { scheduler.scheduleAtFixedRate(() - { DeviceMessage hb new DeviceMessage(); hb.setMsgId(UUID.randomUUID().toString()); hb.setType(MessageType.HEARTBEAT); hb.setDeviceId(deviceId); hb.setTs(System.currentTimeMillis()); channel.writeAndFlush(JSON.toJSONString(hb) \n); }, 5, 10, TimeUnit.SECONDS); } private void handleServerMessage(String raw) { DeviceMessage msg JSON.parseObject(raw, DeviceMessage.class); if (MessageType.COMMAND.equals(msg.getType())) { System.out.println(设备收到控制指令: msg.getPayload()); DeviceMessage ack new DeviceMessage(); ack.setMsgId(msg.getMsgId()); ack.setType(MessageType.ACK); ack.setDeviceId(deviceId); ack.setCode(0); ack.setPayload(执行成功); channel.writeAndFlush(JSON.toJSONString(ack) \n); } } }模拟器里的定时器要和服务端的 IdleStateHandler 心跳超时参数对得上。服务端如果设置 30 秒未收到消息就判定离线模拟器心跳间隔就不能是 60 秒。这里我设的是 10 秒一次留够了网络抖动余量。设备注册要有幂等意识模拟器重连时会重复发 REGISTER服务端对同一 deviceId 更新 Channel 即可不需要把旧连接踢掉。4.2 端到端验证启动模拟器后调用控制接口看完整链路服务端和控制端的接口要能对设备发指令先注册一个模拟器再通过 REST 接口下发这是整个系统第一次“闭环”。先启动 Spring Boot 工程再单独跑 DeviceSimulator 的 main 方法然后调用控制接口# 把 light-001 打开payload 里是控制参数 curl -X POST http://localhost:8080/device/control \ -H Content-Type: application/json \ -d { deviceId: light-001, command: turnOn, params: {brightness: 80} }接口层返回的是“指令已受理等待设备回执”的状态真正的成功标志是模拟器控制台打印出来的那一行“设备收到控制指令”以及随后服务端打印的 ACK 日志。调用之前要确认模拟器完成了 REGISTER否则服务端 deviceChannels 里没有这个设备指令会直接被拦截并返回DEVICE_OFFLINE。这里第一次联调最常见的现象是模拟器先启动服务端后启动或者反过来连接被重置后模拟器没有重连逻辑导致后续指令全部失败。模拟器代码里应加上断线自动重连这也是真实设备固件的必备能力channel.closeFuture().addListener(future - { System.out.println(连接断开准备重连...); try { connect(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });4.3 设备不在线怎么办离线指令的暂存与补偿真实场景里设备经常会离线用户在 App 上点“回家前开空调”设备恰好断电了。如果服务端直接丢弃指令用户的体验就是“点了没反应”。常见做法是把指令暂存下来等设备上线后补偿下发。在 service 层加一个离线指令队列指令先尝试实时下发Channel 不存在或虽然存在但发送失败就把指令放进以 deviceId 为 key 的队列里private final MapString, QueueDeviceMessage offlineQueue new ConcurrentHashMap(); public boolean sendOrStore(DeviceMessage command) { Channel channel deviceMessageHandler.getChannel(command.getDeviceId()); if (channel ! null channel.isActive()) { channel.writeAndFlush(JSON.toJSONString(command) \n); return true; } offlineQueue.computeIfAbsent(command.getDeviceId(), k - new ConcurrentLinkedQueue()).offer(command); return false; }设备重新注册成功后由设备网关回调 service 层的补偿方法把队列里的指令依次下发。补偿时机很关键一定要在设备上线稳定后发不能刚注册就狂吐积压指令否则设备端还没准备好接收指令补偿又变成新的超时。实践里服务端可以在 REGISTER 完成之后延迟 2 秒再补偿给设备留出接口初始化时间。队列要做大小限制比如单设备最多暂存 50 条超出直接丢弃并告警防止离线久了内存被堆爆。5. 避坑指南智能家居远程控制系统上线前后最容易翻车的 5 个问题5.1 Netty Handler 里做耗时操作导致控制指令集体超时现象设备数量只有几十台的时候一切正常模拟器加到两三百台控制端频繁报指令超时服务端 CPU 不高但 Netty 线程池的线程全部处于 RUNNABLE 状态卡在某个调用上。原因Handler 的 channelRead 里直接执行了数据库查询或同步 HTTP 调用。Netty 的 IO 线程数量默认是 CPU 核数的两倍这个线程池要处理所有连接的读写事件一个连接的业务阻塞会把共享线程占住后面的设备消息全部排队。解决把业务处理逻辑全部丢进独立业务线程池IO 线程只做解析和分发。线程池不要用无界队列ArrayBlockingQueue 加上 CallerRunsPolicy让背压机制在业务负载过高时反向影响 IO 线程。Handler 里禁止使用 Thread.sleep哪怕测试也不行。5.2 设备断网但状态仍显示“在线”的心跳误判现象把设备模拟器强制 kill 掉服务端设备列表里这个设备仍然显示“在线”控制端下发指令返回成功但设备根本没执行。原因没有应用层心跳检测TCP 连接在物理断开时不会立刻通知对端。设备被 kill、网线被拔、路由器重启TCP 连接可能处于半开状态服务端以为连接还在。解决设备端定时发 HEARTBEAT服务端用 Netty 的 IdleStateHandler 做超时检测。服务端 pipeline 里加IdleStateHandler(30, 0, 0)Handler 重写 userEventTriggered在 READER_IDLE 事件里关闭连接并清理会话。客户端每 10 秒发一次心跳服务端 30 秒没收到数据就判离线这个比例要留足余量避免 NAT 超时或网络抖动导致误杀。5.3 弱网导致指令重复执行msgId 幂等怎么落现象一条“打开空调 26 度”的指令设备实际执行了两次用户看到空调开了一下又关了一下。原因设备执行成功后 ACK 回执在网络传输中丢失服务端认为超时按重试策略重新下发同一条指令设备没有按 msgId 去重。解决设备端必须维护最近处理过的 msgId 缓存收到 COMMAND 时先查这个 set已处理过的直接回 ACK 但跳过执行。服务端在之一次下发时带上 msgId重试时复用同一个 msgId而不是重新生成。设备侧去重缓存用固定大小的环形结构比如保存最近 500 条防止内存增长失控。服务端的离线补偿也能复用这套幂等机制设备上线后拉取离线指令时重复指令通过 msgId 天然被滤掉。5.4 中文参数乱码ByteBuf 编解码两边编码不一致现象指令参数里带中文如params: {name:客厅灯}设备端收到的字符串变成了乱码解析直接抛异常。原因服务端 StringDecoder 用 UTF-8但设备端构造 ByteBuf 时用默认字符集或者 Win 环境下默认 GBK两边都没有显式指定编码。Netty 的 StringDecoder 如果不传 Charset 参数默认使用平台字符集Windows 上的 GBK 与服务端的 UTF-8 对不上就乱。解决编解码器统一显式传StandardCharsets.UTF_8设备端、服务端、控制端三个环节全部钉死 UTF-8。报文尾部追加的换行符\n不受影响但 JSON 字符串内部如果含有中文必须确保发送时writeAndFlush(JSON.toJSONString(...).getBytes(StandardCharsets.UTF_8))。排查乱码时先用日志把原始 bytes 打到十六进制对比E9 A2 9D如果是C3 A9这种双字节形态基本可以断定编码不一致。5.5 设备端收不到指令先查端口、防火墙和 Channel 是否被替换现象设备正常上线状态上报正常但控制端下发指令后设备没有任何反应服务端也没报错。原因大概率不是代码逻辑问题而是三个环境问题。端口 7123 被防火墙拦截公司内网或云服务器安全组没放行该端口设备连接的是测试环境 IP控制端请求打到的是另一个服务实例或者设备断线重连后 Handler 里的 deviceChannels 被旧连接的事件错误清理。解决先看服务端日志有没有设备上线记录再看模拟器是否注册成功。用netstat -ano | findstr 7123(Windows) 或ss -lntp | grep 7123(Linux) 确认端口监听。云服务器要在安全组和系统防火墙两层放行 TCP 7123。最后在 CommandService 下发时打印channel.isActive()和 channel 的远程地址对比设备实际连接地址能立刻看出 Channel 是否被替换。这类问题八成都出在环境而非代码按这个顺序排查效率最高。注意注册表里存的是 keydeviceId、valueChannel 的映射排错时要确认这个 value 不是重连前的旧连接。可以在 Channel 的 attr 里塞一个 connectionId日志里打出来就能区分新旧连接。6. 从能跑到能上线压测、离线指令持久化与入口 token 的三个必做动作6.1 用 JMeter 压一遍指令链路并发 200 时看回执率系统跑通后先别急着加功能用 JMeter 对/device/control接口做一轮最小压测。线程组设 200 并发循环 20 次每个请求用随机 deviceId。服务端配合日志打点统计“下发次数”和“收到 ACK 次数”回执率掉到 90% 以下说明线程池或队列参数有问题。JMeter 跑完看一眼聚合报告里的响应时间分布如果 P95 超过 3 秒优先检查设备模拟器的处理能力本地模拟器单线程解析 JSON 会成为瓶颈。6.2 离线指令落库与查询H2 里建一张 command 表内存队列重启就丢课程设计加上持久化才算完整闭环。引入 H2 数据库建一张指令流水表CREATE TABLE command_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, msg_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL, payload TEXT, status TINYINT NOT NULL DEFAULT 0, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_id (msg_id) );status 字段标记指令状态0 待下发、1 已下发、2 设备已确认、3 超时。设备上线补偿时从库里查 status0 且 device_id 匹配的记录逐条重发收到 ACK 后更新状态。这张表同时给 msgId 幂等提供持久化依据重启后去重数据不丢。索引按(device_id, status)建联合索引设备上线时查补偿列表就不会全表扫描。6.3 设备注册 token别只用 IP 当身份最后的补全动作是设备鉴权只用 IP 和设备 ID 认设备完全不安全。常见做法是服务端为每台设备预分配 token设备 REGISTER 报文里带上 tokenpublic class RegisterService { public boolean verify(String deviceId, String token) { // 生产环境换成数据库或 Redis 查询 return light-001.equals(deviceId) a1b2c3.equals(token); } }校验失败直接关闭连接连会话表都不放进去。token 不要硬编码在设备源码里设备端放配置项服务端下发设备证书时统一刷写。做完这三件事这套系统才从“能演示”变成“敢说自己设计过”。这是我做这类项目时最后一道工序的习惯先把最简单链路跑通再补鉴权、持久化和压测顺序反了一旦出问题你分不清是网络问题还是业务逻辑问题。智能家居远程控制的复杂度不在一两个接口而在设备会话、消息幂等和超时补偿这些细节上把这一套源码从头到尾调通一次你对 Java 服务端工程的理解会比刷几周面试题更扎实。希望帮到你。本文还有配套的精品资源点击获取