简介:这是一份基于Spring Boot与Vue的智能家居管理系统完整项目源码,适用于Java方向的毕业设计、课程设计以及Spring Boot入门实战。项目采用前后端分离架构,整合MySQL、MyBatisPlus、Ajax、Maven等主流技术,完整实现用户信息管理、图片素材管理、视频素材管理等功能模块,代码结构清晰,方便快速理解业务逻辑。压缩包内共有368个文件,以Java后端源码、Vue前端页面、SVG图标资源为主,同时包含XML配置、样式表、启动脚本和使用说明文档,整体大小15.22MB,目录组织合理,便于按需检索。附带的批处理脚本可一键启动部署,必读推荐文档提供了项目背景与使用指引,能够帮助读者快速运行项目、对照源码学习Spring Boot开发流程,并理解数据库表设计与接口调用方式。目前已有239人学习下载,适合需要完整参考项目与实操指引的开发者。
1. 智能家居系统用Spring Boot做后端:这到底是做什么的项目
基于Spring Boot的智能家居系统,核心不是硬件和App,而是一个Java服务端管理系统。它接收设备上报的数据,维护设备和家庭的绑定关系,跑场景联动规则,再下发控制指令,同时给前端提供分页查询、操作日志、用户管理接口。换句话说,这套代码解决的是“设备有了、App有了,中间没人管”的问题。适合两类人:一是想用Java生态快速交付可维护管理后台的团队,二是需要完整后端代码做课程或私活的开发者。下文按一个可直接运行的骨架来讲,包括表结构、接口代码、设备接入和场景联动,以及那些只有跑过才会遇到的坑。
2. 从方案到表结构:智能家居管理系统的模块拆分与数据库设计
做智能家居管理系统,最怕一上来就写代码。先要把边界、链路和数据模型定住,否则后面设备类型一多,表结构改起来非常痛苦。下面这套拆分方式我用了不少项目,兼容“家里只有几台设备”的小项目,也撑得起“多家庭多用户”的交付。
2.1 先定边界:Spring Boot在智能家居系统里管哪一段
智能家居整体通常分端、云、管三个层面。设备侧跑固件和通信协议;接入侧负责处理高并发的设备连接和消息收发;管理侧承担用户、家庭、设备、场景、日志这些业务逻辑。基于Spring Boot的管理系统,落点就在管理侧,同时通过HTTP回调或MQTT订阅的方式与接入侧通信。不要指望Spring Boot默认配置去扛百万设备连接,那是接入网关的事。
Spring Boot的价值在于业务表达力强、生态全、部署简单。对比Node.js和自研框架,Spring Boot在事务、定时任务、权限这些管理系统刚需上有现成方案。Node.js的事件模型适合高并发IO,但配合关系型数据库做复杂报表和管理功能,团队配合成本会更高。常见做法是:管理端用Spring Boot,接入层用独立的MQTT服务端或Netty网关,两边通过消息和回调解耦。
| 方案 | 并发能力 | 业务开发效率 | 团队招人 | 适合场景 |
|---|---|---|---|---|
| Spring Boot | 中等,可横向扩容 | 高,生态完整 | 容易 | 管理后台、规则引擎 |
| Node.js | 高IO并发 | 中,需自建模块 | 看地区 | 轻量推送聚合 |
| 自研Java网关 | 高 | 低,维护成本高 | 难 | 设备接入专用 |
上表不是要分高下,而是提醒:把Spring Boot当设备接入网关用,并发上来会很难受;把它当管理后台用,它能省掉大量重复工作,这也正是本标题指向的核心用法。
2.2 核心链路:上报、入库、判规则、下发、推送
这套管理系统的数据流很固定,理解它之后,代码写起来才有方向。设备上报一条状态,先落到上报日志表,再更新该设备的影子状态,随后触发场景规则匹配,匹配到动作就组装成指令下发,同时把状态变更通过WebSocket或消息推送通知给前端。
链路中的“影子状态”非常重要,这是避免消息乱序和重启丢失的关键。很多项目组只建了上报日志表和设备表,跳过了影子状态,后面查“设备为什么状态不对”会花掉大量时间。在后面的章节,我会把影子状态设计成Redis里的一个hash结构,带版本号;上报接口先刷新影子,再做规则判断。
这条链路还可以作为验收标准:一次上报,用户能在1秒内看到页面状态变化,并且设备收到指令。如果其中任何一段超过这个时间,先查日志,再看是不是链路里多了没必要的同步调用。
2.3 数据库表设计:设备表、场景表、日志表怎么落地
先看设备表。下面这段SQL可以直接在手写初始化脚本里使用,它把设备、家庭、房间三层关系固定下来:
CREATE TABLE `device` ( `id` bigint(20) NOT NULL COMMENT '主键', `device_no` varchar(64) NOT NULL COMMENT '设备唯一编号', `name` varchar(64) NOT NULL COMMENT '设备名称', `type` tinyint(4) NOT NULL COMMENT '设备类型:1灯 2空调 3门锁', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '当前业务状态', `home_id` bigint(20) NOT NULL COMMENT '所属家庭ID', `room_id` bigint(20) DEFAULT NULL COMMENT '所属房间ID', `last_heartbeat` datetime DEFAULT NULL COMMENT '最后心跳时间', `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '逻辑删除', PRIMARY KEY (`id`), UNIQUE KEY `uk_device_no` (`device_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表';这里的字段设计有几个关键点。device_no是设备出厂或自定义的编号,业务查询都走它,所以加了唯一索引;主键用bigint,在代码里用雪花算法生成,避免依赖数据库自增。status只表示业务状态,比如灯的开关;last_heartbeat单独存,判断在线状态时和当前时间对比,而不是依赖status。逻辑删除deleted字段必须留,设备误删后还能恢复,审计也方便。
场景规则表保存自动化规则:
CREATE TABLE `scene_rule` ( `id` bigint(20) NOT NULL, `name` varchar(64) NOT NULL COMMENT '场景名称', `home_id` bigint(20) NOT NULL COMMENT '所属家庭ID', `trigger_device_no` varchar(64) DEFAULT NULL COMMENT '触发设备编号', `condition_expr` varchar(255) DEFAULT NULL COMMENT '触发条件表达式', `action_json` text COMMENT '动作列表JSON', `enabled` tinyint(4) NOT NULL DEFAULT '1' COMMENT '是否启用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景规则表';condition_expr存的是类似status == 1 && temperature > 28的表达式字符串,action_json存动作列表,比如“打开客厅灯、关闭空调”。这种设计的好处是规则可配置,不用为每一条规则写死代码。上报日志表单独记录每次原始报文,字段包括设备编号、上报内容、创建时间,用于排查问题和回放数据。表结构不是越复杂越好,三张核心表加上用户家庭表,已经能覆盖大部分管理需求。
3. 用Spring Boot把管理后台跑起来:实体、Mapper、接口的落地代码
从这一章开始贴可以抄的代码。下面以Spring Boot 2.7.18 + MyBatis-Plus 3.5.3为例,Java 8以上都能跑。版本号你可以换成自己习惯的,但要保证MyBatis-Plus启动器和Spring Boot版本兼容。
3.1 工程初始化的pom依赖与配置文件
先看pom.xml里最核心的依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies>这段拿掉了所有测试相关依赖,保证最小可运行。mybatis-plus-boot-starter负责ORM和分页;validation用于接口参数校验;data-redis是第4章定时任务和第6章设备影子的基础,如果先做纯管理功能也可以不加,但后面肯定要补。
application.yml是另一个容易翻车的地方:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/smart_home?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root redis: host: localhost port: 6379 jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true这里两个参数值得注意。datasource url里的serverTimezone=Asia/Shanghai和jackson.time-zone=Asia/Shanghai必须同时配,否则会出现数据库时间对、接口返回时间错8小时的典型问题。logic-delete-field是MyBatis-Plus的逻辑删除配置,配合实体里的@TableLogic,所有Mapper内置方法自动带deleted过滤条件。
注意:时区配置要datasource url和jackson两处同时改,只改一处依然会出现8小时偏差。
3.2 实体类与Mapper:用MyBatis-Plus少写一半样板
设备实体类对应2.3的设备表:
@Data @TableName("device") public class Device { @TableId(type = IdType.ASSIGN_ID) private Long id; private String deviceNo; private String name; private Integer type; private Integer status; private Long homeId; private Long roomId; private LocalDateTime lastHeartbeat; @TableLogic private Integer deleted; }@TableId(type = IdType.ASSIGN_ID)是雪花算法主键,适合后面分库分表,也避免插入数据前先去查一次自增ID。@TableLogic标记逻辑删除字段,查询时MyBatis-Plus会自动拼上deleted=0。字段名到下划线列名的映射由yml里的map-underscore-to-camel-case处理。
Mapper接口简洁到只有一行:
public interface DeviceMapper extends BaseMapper<Device> { }BaseMapper自带insert、selectPage、selectCount等常用方法,不需要写XML。需要在XML里处理复杂统计时,再加一个方法并绑定XML文件,平时别手写简单查询。
分页插件要单独配置,否则selectPage返回的total一直是0:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页拦截器是MyBatis-Plus的手动挡,不加它,Page参数不会被解析,查询结果只有当前页数据,分页总数也是错的。DbType.MYSQL告诉插件用MySQL的limit语法。
3.3 设备管理接口:分页、绑定、下发命令
设备管理接口是管理后台最常用的模块。先写Controller:
@RestController @RequestMapping("/api/device") @RequiredArgsConstructor public class DeviceController { private final DeviceService deviceService; @GetMapping("/page") public Result<IPage<DeviceVO>> page( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) Long homeId) { return Result.ok(deviceService.pageDevices(page, size, homeId)); } @PostMapping("/bind") public Result<Boolean> bind(@RequestBody @Valid BindDeviceReq req) { return Result.ok(deviceService.bindDevice(req)); } @PostMapping("/command") public Result<Void> command(@RequestBody DeviceCommandReq req) { deviceService.sendCommand(req); return Result.ok(); } }page接口的page和size都给了默认值,避免前端不传时直接把整个表查出来。homeId是可选的,不传表示查全部,传了就精确过滤。bind接口负责设备入网绑定,body里通常包含deviceNo、name、type、homeId、roomId。command接口是管理后台手动下发控制指令的入口。
Service实现里,分页查询和绑定逻辑这样写:
@Service @RequiredArgsConstructor public class DeviceServiceImpl implements DeviceService { private final DeviceMapper deviceMapper; @Override public IPage<DeviceVO> pageDevices(Integer page, Integer size, Long homeId) { Page<Device> p = new Page<>(page, size); LambdaQueryWrapper<Device> qw = new LambdaQueryWrapper<>(); qw.eq(Device::getDeleted, 0); qw.eq(homeId != null, Device::getHomeId, homeId); qw.orderByDesc(Device::getLastHeartbeat); return deviceMapper.selectPage(p, qw).convert(this::toVO); } @Override @Transactional(rollbackFor = Exception.class) public Boolean bindDevice(BindDeviceReq req) { Long count = deviceMapper.selectCount( new LambdaQueryWrapper<Device>() .eq(Device::getDeviceNo, req.getDeviceNo()) .eq(Device::getDeleted, 0)); if (count > 0) { throw new BizException("设备编号已存在"); } Device device = new Device(); BeanUtils.copyProperties(req, device); device.setStatus(0); return deviceMapper.insert(device) > 0; } }pageDevices里的条件构造器用了eq(condition, column, value)写法,homeId为null时这条条件自动跳过,比手动拼接SQL安全得多。orderByDesc(Device::getLastHeartbeat)让最近活跃的设备排前面。bindDevice先查重,再插入;@Transactional(rollbackFor = Exception.class)保证异常时回滚。BeanUtils.copyProperties是Spring自带的工具,只拷贝同名属性,注意req里的字段要和Device字段命名一致。
sendCommand的实现方式取决于设备通信渠道。如果走MQTT,就用MqttTemplate发一条指令到设备topic;如果走HTTP,就调用设备端暴露的回调接口。管理后台这里只负责组装指令并发送,不关心设备固件怎么响应,这样前后端和硬件的边界就清晰了。
4. 设备接入与场景联动:上报接口、规则判断和定时任务的工程实现
管理后台的接口写得再漂亮,接不上设备也是白搭。这一章解决两个问题:设备怎么把状态送到系统里,系统怎么根据状态自动联动。
4.1 设备上报的两种接入方式:HTTP回调与MQTT订阅怎么选
设备上报最常见的两种方式是HTTP回调与MQTT订阅。
| 维度 | HTTP回调 | MQTT订阅 |
|---|---|---|
| 设备端实现 | 简单,HTTP Post即可 | 需要MQTT客户端,稍复杂 |
| 服务端压力 | 每台设备一个请求 | 长连接,消息量大时更稳 |
| 实时性 | 取决于设备策略 | 消息即到 |
| 适合场景 | 少量设备、门锁、音箱 | 批量传感器、频繁状态上报 |
我一般这样建议:项目初期设备量不大,或者接入的都是带云端的成品设备,用HTTP回调最快;如果自己做网关、接大量传感器,或者需要设备和系统保活,MQTT更合适。对于基于Spring Boot的管理系统,两种方式都可以共存,上报入口统一走同一个Service,只是协议层不同。
HTTP上报接口的代码很简单:
@RestController @RequestMapping("/api/device") public class DeviceReportController { @PostMapping("/report") public ReportResult report(@RequestBody DeviceReportReq req) { DeviceShadow shadow = shadowService.refresh(req); List<SceneRule> matchedRules = sceneRuleService.findMatched(req.getDeviceNo(), shadow); List<String> commands = sceneExecutor.execute(matchedRules, req.getHomeId()); return ReportResult.of(shadow, commands); } }report接口不做任何权限校验,因为它暴露给设备网关调用,而不是浏览器。设备端传的body里至少包含deviceNo、status、上报时间,还可以带temperature、humidity这些扩展属性。shadowService.refresh先更新设备的影子状态,后面的规则匹配全部基于影子而不是原始报文,这样能避免消息乱序。
4.2 MQTT接入配置与消息监听
MQTT方式需要额外的依赖:
<dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-mqtt</artifactId> </dependency>配置类负责和MQTT服务端建立连接:
@Configuration public class MqttConfig { @Bean public MqttPahoClientFactory mqttPahoClientFactory() { DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); MqttConnectOptions options = new MqttConnectOptions(); options.setServerURIs(new String[]{"tcp://192.168.1.10:1883"}); options.setUserName("smart_home"); options.setPassword("smart_home_pwd".toCharArray()); options.setAutomaticReconnect(true); factory.setConnectionOptions(options); return factory; } @Bean public MessageProducer mqttInbound() { MqttPahoMessageDrivenChannelAdapter adapter = new MqttPahoMessageDrivenChannelAdapter("smart-home-backend-001", mqttPahoClientFactory(), "device/+/report"); adapter.setQos(1); adapter.setCompletionTimeout(5000); adapter.setOutputChannel(mqttInputChannel()); return adapter; } @Bean public MessageChannel mqttInputChannel() { return new DirectChannel(); } }这段配置里,client-id必须全局唯一,多个服务实例共用同一个client-id会互相踢下线。topic写成device/+/report,+号匹配任意设备编号,一个监听器就能接收所有设备上报。QoS设为1,保证消息至少到达一次,不丢消息,但可能出现重复,所以消费端要做幂等。
消息监听类负责把MQTT消息转成业务对象:
@Component public class MqttDeviceMessageHandler { @ServiceActivator(inputChannel = "mqttInputChannel") public void handle(Message<?> message) throws Exception { String topic = (String) message.getHeaders().get("mqtt_receivedTopic"); String payload = new String((byte[]) message.getPayload(), StandardCharsets.UTF_8); DeviceReportReq req = JSON.parseObject(payload, DeviceReportReq.class); reportService.handleReport(topic, req); } }协议层只认topic和payload,业务逻辑全部下沉到reportService,和HTTP回调共用同一套处理逻辑。这样后面换协议时,Controller和MQTT监听类都会很薄,真正的坑都集中在Service层。
4.3 场景联动规则的匹配与执行
场景规则的表结构已经在第2章给了,实体这样设计:
@Data @TableName("scene_rule") public class SceneRule { private Long id; private String name; private Long homeId; private String triggerDeviceNo; private String conditionExpr; private String actionJson; private Integer enabled; }匹配逻辑的核心是条件表达式求值。这里直接用Spring自带的SpEL,不用额外引入规则引擎:
public boolean match(SceneRule rule, DeviceShadow shadow) { if (StringUtils.hasText(rule.getTriggerDeviceNo()) && !rule.getTriggerDeviceNo().equals(shadow.getDeviceNo())) { return false; } String expr = rule.getConditionExpr(); if (StringUtils.isBlank(expr)) { return true; } Map<String, Object> ctx = new HashMap<>(); ctx.put("status", shadow.getStatus()); ctx.put("temperature", shadow.getTemperature()); ctx.put("humidity", shadow.getHumidity()); ExpressionParser parser = new SpelExpressionParser(); return Boolean.TRUE.equals( parser.parseExpression(expr).getValue(ctx, Boolean.class)); }SpEL表达式里可以直接写 status == 1 && temperature > 28,配置场景时把表达式存到scene_rule表,改规则不用改代码。这里有一个安全边界:条件表达式只允许后端预设,不能接受前端用户传的表达式,因为SpEL能力太强,开放给用户会变成安全隐患。匹配到规则后,把actionJson解析成动作列表,逐个执行。
动作执行器增加递归深度限制,防止场景互相触发形成死循环:
public void executeScenes(SceneContext ctx) { if (ctx.getDepth() > 10) { log.warn("scene loop aborted, chain={}", ctx.getChain()); return; } for (DeviceAction action : ctx.getActions()) { commandSender.send(action); } }深度限制只是兜底,更严谨的做法是给每次上报生成一个事件ID,规则执行链携带事件ID,已经处理过的事件直接跳过。
4.4 定时场景的调度实现
场景不只是基于设备状态触发,还有“每天7点开灯”这种定时需求。定时场景用Spring的@Scheduled就能实现:
@Component public class SceneTimerTask { @Scheduled(cron = "0 */1 * * * ?") public void scanTimedScene() { List<TimedScene> scenes = timedSceneMapper.selectList( new LambdaQueryWrapper<TimedScene>() .eq(TimedScene::getEnabled, 1)); for (TimedScene scene : scenes) { String lockKey = "lock:timed_scene:" + scene.getId() + ":" + scene.getTriggerTime(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "doing", Duration.ofSeconds(60)); if (Boolean.TRUE.equals(locked)) { try { sceneExecutor.executeScene(scene); } finally { redisTemplate.delete(lockKey); } } } } }cron表达式“0 */1 * * * ?”表示每分钟执行一次扫描,定时场景的精确触发时间由业务代码判断,避免每条规则一个定时器导致任务爆炸。Redis锁解决多实例部署时的重复执行问题:同一个场景同一分钟只有一台实例抢到锁。锁的过期时间设为60秒,防止执行异常时锁不释放;finally里主动删锁,避免一个实例执行完,另一个实例还在等锁白白浪费时间。
5. 智能家居系统常见问题与排查:五条一言难尽的踩坑记录
这五条是从真实项目里踩出来的,每一条都按现象到原因再到解决的顺序写。早看到早避开。
5.1 上报时间总是差8小时
现象:设备上报后,数据库里last_heartbeat比当前时间慢了8小时,App上显示“最后在线时间是昨天”。原因:MySQL连接串和Jackson时区没有统一,数据库连接按UTC处理时间,接口序列化LocalDateTime又按JVM默认时区,两个时区叠出8小时偏差。解决:在application.yml里把两个地方都设为Asia/Shanghai:
spring: datasource: url: jdbc:mysql://localhost:3306/smart_home?serverTimezone=Asia/Shanghai&useSSL=false jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss改完配置后,把旧数据重新插入一次验证,不要只改配置不验证,时区问题经常被缓存或既有数据干扰。
5.2 MQTT重连后状态却被旧消息覆盖
现象:设备断网重连后,后台设备状态变成旧值,明明设备最新状态是“关闭”,页面上却显示“开启”。原因:MQTT的retain消息在设备重连后立刻推过来,这条旧消息到达时间晚于实时消息,直接把最新状态覆盖了。解决:设备影子带上版本号,消费端先比版本,再决定要不要更新:
Long newVersion = message.getVersion(); Long current = redisTemplate.opsForHash().increment(shadowKey, "version", 0); if (newVersion < current) { log.info("ignore stale message, deviceNo={}, version={}", deviceNo, newVersion); return; }这个方案要求设备端每次上报带一个自增版本号,或者服务端用收到时间戳排序。没做影子之前,这类问题极难复现,因为它只在网络抖动时出现。
5.3 场景联动一触发就停不下来
现象:一个“回家开灯”动作刚好触发了另一个“检测到有人开灯”的场景,那个场景又触发“回家开灯”,设备反复开关。原因:场景动作执行没有携带触发链ID,也没有递归深度限制。解决:执行器维护深度计数器,超过10层直接中断;同时每个动作记录本次触发链ID,同一个事件处理过一次就不再处理。代码片段:
if (ctx.getDepth() > 10) { log.warn("scene loop aborted, chain={}", ctx.getChain()); return; }深度限制不解决所有循环,但能把风险锁在有限范围内。上线后要加一个“场景执行日志”表,记录每次触发的规则链,排查死循环时有据可查。
5.4 @Transactional同一个类里自调用不生效
现象:绑定设备和初始化默认场景两步操作,第二步抛异常后第一步没有回滚,数据库里留下一个“孤儿设备”。原因:在同一个Service里通过this调用另一个@Transactional方法,自调用绕过Spring代理,事务切面根本没有执行。解决:把两个操作拆到不同Service,或者在当前类注入自己,用代理对象调用:
@Service public class DeviceServiceImpl { @Autowired private SceneServiceImpl sceneService; @Transactional(rollbackFor = Exception.class) public void bindAndInitScene(BindDeviceReq req) { bindDevice(req); sceneService.initDefaultScenes(req.getHomeId()); } }跨Service调用时事务才会被代理拦截。如果非得自调用,就在类里注入自己,例如@Autowired private DeviceServiceImpl self;再用self.init()。这个坑只花一分钟避开,但排查往往要半天。
5.5 重启后设备集体掉线
现象:Spring Boot一重启,App上所有设备变成离线,过几分钟才陆续恢复。原因:在线状态只存在内存Map里,服务启动时Map是空的,系统不知道哪些设备刚发过心跳,于是全部判定离线。解决:在线状态放Redis,带TTL,每次心跳续期:
public void heartbeat(String deviceNo) { redisTemplate.opsForValue().set( "online:" + deviceNo, "1", Duration.ofSeconds(90)); }查询在线状态时读Redis,key存在就算在线。Redis本身如果重启,顶多丢失90秒的心跳记录,设备下一次心跳会自动补回来,不会出现“群体离线”的误报。
6. 再往前一步:设备影子、WebSocket推送与链路验证的最小闭环
如果前面的代码你已经跑通了,下一步建议把设备影子和WebSocket推送补上,再把整条链路用curl验一次。
6.1 设备影子与状态推送的最小闭环
设备影子是设备最新状态的带版本号镜像,放在Redis里:
public DeviceShadow refresh(DeviceReportReq req) { String key = "shadow:device:" + req.getDeviceNo(); Long version = redisTemplate.opsForHash().increment(key, "version", 1); redisTemplate.opsForHash().put(key, "status", req.getStatus()); redisTemplate.expire(key, Duration.ofMinutes(30)); return DeviceShadow.of(req.getDeviceNo(), version, req.getStatus()); }version每次上报自增,消费端通过比对版本丢弃乱序消息;TTL 30分钟避免长期不工作的设备占用Redis。业务表里查到的状态都来自这个影子,而不是直接写device表。前端要实时看到状态变化,用WebSocket推送:
messagingTemplate.convertAndSend( "/topic/home/" + homeId + "/device", shadow);使用Spring WebSocket的STOMP协议时,前端订阅/topic/home/{homeId}/device,后端一次上报,所有在线页面都能收到状态变更,不依赖轮询。这个闭环完成后,上报延迟基本在百毫秒内。
6.2 用curl验证整条链路
设备端POST一条JSON到上报接口,代替真实设备:
curl -X POST http://127.0.0.1:8080/api/device/report \ -H "Content-Type: application/json" \ -d '{"deviceNo":"DEV-001","status":1,"temperature":26.5}'然后分别检查三处:接口返回的commands里有几条规则动作;日志里有没有打印场景规则命中记录;Redis里HGETALL shadow:device:DEV-001的version是否在递增。这套验证方法可以拿来做回归测试:改一次规则,跑一遍curl,看结果是否符合预期。有开发者朋友接过一个设备接入项目,为了赶进度省了设备影子,结果一条重放日志让全家灯具开了一整晚。我后来养成的习惯是:凡是设备状态写入业务表,必须先过影子;凡是规则执行,必须先看深度限制。这个顺序哪怕在最小可运行版本里也值得保留。希望帮到你。
本文还有配套的精品资源,点击获取