Java物联网实战:宠物自助洗澡机系统源码剖析

发布时间:2026/10/14 5:41:13
Java物联网实战:宠物自助洗澡机系统源码剖析
这套设备出现在不少商场、宠物店门口用户扫码支付后带着宠物进去自己动手完成洗浴和烘干全程不用店员守着。设备本身不复杂真正有门道的是它背后的物联网系统——设备端要能稳定上报状态云端要能准确计费、远程下发指令小程序要能实时展示进度这三方协同才是核心工作量。我最近完整梳理了一个基于Java实现的宠物自助洗澡物联网项目源码从设备主控到云端服务再到小程序端整个链路都过了一遍这篇就把源码层面的模块设计、通信协议、状态机、坑点处理逐一拆开尽量把每个关键环节讲透。1. 项目全貌与架构拆解1.1 这套系统到底在解决什么问题先还原一下真实的使用场景。用户在商场看到一台宠物洗澡机玻璃门上贴着二维码手机扫码后进入小程序页面选择标准洗或者精洗支付预授权费用设备解锁用户把宠物抱进舱体关上门点击小程序上的开始洗浴设备开始按设定程序自动注水、喷洒沐浴液、冲洗、烘干。整个过程按分钟计费结束后从预授权里扣款多余金额原路退回。这套流程涉及三方角色设备端单片机传感器执行器、云端服务Java后端、用户端小程序。设备端要采集水位、温度、门锁状态控制水泵、风机、加热器云端要管理设备注册、处理MQTT消息、维护订单状态机、对接支付渠道小程序要负责扫码流程、实时状态展示、远程控制指令。任何一个环节断链都会直接影响用户体验和商家收入。实际开发中最容易被低估的是计费一致性问题。用户提前结束洗浴、设备中途故障、网络闪断导致状态丢失这些场景都会让设备端、云端、支付三方的数据对不上。源码里应对这些问题的思路很值得参考——它不是靠单一的强一致方案而是通过设备影子、离线补单、状态机幂等设计组合解决。1.2 整体架构与通信链路从源码项目结构看这是一个标准的物联网三段式架构设备端基于STM32系列单片机通过4G模组或Wi-Fi模组接入网络采用MQTT协议与云端通信云端Spring Boot应用内嵌Netty实现的MQTT Broker同时提供REST API给小程序调用用户端微信小程序通过HTTP接口完成业务操作通过WebSocket接收设备实时状态推送数据链路大概是这样的设备传感器 - 单片机采集 - MQTT上报 - 云端Broker - 业务模块处理 - 状态存储 ^ | | MQTT指令下发 - 指令队列 - 小程序WebSocket请求设备端和云端之间走MQTT这是物联网场景的行业惯例。MQTT基于发布订阅模型开销小、支持离线遗嘱消息非常适合这种网络不稳定的设备端。比较特殊的是很多同类项目直接把第三方MQTT Broker比如EMQX、Mosquitto拿来用但这个项目选择在Java服务里自研/内嵌了一个轻量Broker核心原因是部署成本和定制空间。内嵌Broker可以复用已有的设备认证体系不用额外维护一套账号体系而且后续如果要做指令拦截、协议自定义直接在应用层操作就行不用去改Broker的插件。1.3 为什么主技术栈选Java我拆这个项目之前也有个疑问物联网设备端控制普遍用C/C云端选Java是主流但网关协议适配层用Java会不会有性能问题看完代码后确认这个选择是合理的。核心业务是订单管理和计费Spring Boot MySQL Redis这套组合本身就是业务系统的最优解开发效率高事务控制成熟。MQTT消息量在宠物洗澡机场景下非常低——一台设备5秒上报一次状态就算一个城市铺500台设备每秒也才100条消息Netty完全扛得住根本不需要引入Kafka之类的消息中间件增加复杂度。如果用的是Node.js虽然事件模型对高并发更友好但在事务一致性、支付对接这种严肃场景下周边生态和人员熟悉度都不如Java稳妥。Go倒是不错但团队如果更熟悉Spring全家桶没必要为了看起来高性能换赛道。技术选型没有绝对的对错匹配业务规模和团队能力才是关键。2. 核心模块实现细节2.1 设备接入层MQTT连接、心跳与遗嘱消息设备接入是整个系统的地基。设备第一次通电后需要完成配网、认证、订阅Topic、开始周期性上报状态。源码里有几个细节做得比较到位。Topic设计Topic命名直接决定后续业务代码的简洁程度这个项目采用了一个典型的分层命名格式Topic方向用途petwash/{deviceId}/telemetry设备 - 云端上报温度、水位、运行状态等遥测数据petwash/{deviceId}/status设备 - 云端上报在线/离线/故障等设备状态petwash/{deviceId}/command云端 - 设备下发控制指令开始、停止、解锁petwash/{deviceId}/shadow双向同步设备影子期望状态和实际状态在Broker的订阅处理里对Topic做了通配符匹配。比如小程序端只需要订阅petwash//status就能收到所有设备的状态变更通知这是MQTT通配符订阅带来的便利。心跳与遗嘱设备端每30秒发送一次心跳消息云端如果在90秒内没有收到任何消息就认为设备离线。这里有一层关键保护——遗嘱消息LWT。设备连接Broker时可以指定一个遗嘱主题和遗嘱内容如果设备异常掉线断电、断网Broker会主动向遗嘱主题发布一条离线消息。// 设备连接时配置遗嘱消息 MqttConnectOptions options new MqttConnectOptions(); options.setCleanSession(true); options.setKeepAliveInterval(30); options.setWill(petwash/ deviceId /status, {\online\:false,\offlineReason\:\abnormal\}.getBytes(), 1, false);这个机制的价值在于设备断电瞬间云端能立刻感知离线而不是等90秒超时。对于商家来说设备离线意味着无法接单早一分钟知道就能早一分钟处理。源码里还针对正常离线设备主动发送下线消息和异常离线遗嘱触发做了区分在运维告警里分别处理正常下线不告警异常下线立即通知运营人员。2.2 订单与计费状态机设计订单状态是整个系统最核心的领域模型。设备端、云端、小程序都围绕订单状态协同。源码里用枚举明确定义了状态流转这点做得很好避免了魔法数字和逻辑分散。public enum OrderState { CREATED(0, 已创建), LOCKED(1, 已锁定设备), WASHING(2, 洗浴中), PAUSED(3, 已暂停), COMPLETED(4, 已完成), CANCELED(5, 已取消), REFUNDING(6, 退款中), SETTLED(7, 已结算); private final int code; private final String desc; }完整状态流转严格遵循以下路径CREATED - LOCKED - WASHING - COMPLETED - SETTLED \- CANCELED用户未开始洗浴取消 \- PAUSED - WASHING设备故障恢复后继续注意CANCELED只允许出现在CREATED和LOCKED阶段——一旦进入WASHING就不能直接取消只能正常结束或者故障退款这是因为水、电、沐浴液已经实际消耗无法回退。计费逻辑是另一个容易出错的地方。宠物洗澡机的计费模型是按分钟计费预授权冻结。用户扫码时先冻结一笔钱比如99元洗浴结束后按实际分钟数扣款剩余解冻。public BigDecimal calculateFee(OrderEntity order) { long minutes Duration.between( order.getWashStartTime(), order.getWashEndTime() ).toMinutes(); // 不足1分钟按1分钟计算避免设备频繁启停造成计费纠纷 if (minutes 0) { minutes 1; } return PriceConfig.getPricePerMinute() .multiply(BigDecimal.valueOf(minutes)); }这里踩过一个坑如果设备中途暂停比如用户临时开门调整宠物位置暂停时间必须从计费时长里扣除。源码里设计了PAUSED状态并在OrderEntity里冗余了pauseDurationSeconds字段每次从暂停恢复时累加最终计费时用总时长减去暂停时长。2.3 设备影子解决离线指令下发难题物联网场景最经典的问题设备不在线用户在小程序上点了远程解锁指令下发给谁等设备上线后指令还会不会执行这个项目的方案是设备影子Device Shadow。云端维护一个JSON结构包含两个核心字段{ deviceId: WASHER-001, state: { desired: { doorLock: unlock, mode: standard }, reported: { doorLock: locked, mode: idle } } }desired是云端期望设备达到的状态reported是设备上报的实际状态。当设备在线时指令直接下发同时更新desired当设备离线时只更新desired等设备重连后设备端会主动拉取desired和reported做对比若不一致就执行同步动作。public void handleCommand(DeviceCommand cmd) { // 更新影子期望状态 shadowService.updateDesired(cmd.getDeviceId(), cmd.getState()); // 如果设备在线立即下发 if (deviceRegistry.isOnline(cmd.getDeviceId())) { mqttGateway.publishCommand(cmd); } // 如果离线影子会自动处理等设备上线后拉取 }这个设计在宠物洗澡机场景里很实用。商家在后台把某台设备的模式从标准洗改成精洗那台设备却断电了改完配置后不需要管它什么时候上线等它通电连上网会自动同步最新配置。这也是物联网系统里最终一致性的典型应用。2.4 小程序端的实时状态交互小程序端的交互链路分两条。一条是常规的HTTP API处理扫码创建订单、查询订单列表、发起退款等操作。另一条是WebSocket长连接接收设备状态推送。源码里云端用了一个轻量级的推送通道——设备状态变更时Spring Boot通过WebSocket把消息推送给对应的小程序会话。这里没有用MQTT over WebSocket那一套重型方案因为小程序的实时性要求没那么高状态刷新慢一两秒用户可以接受但WebSocket实现简单、部署方便。关键在于会话管理。用户扫码后小程序建立WebSocket连接同时向云端发送一个绑定消息把userId和connectionId关联起来。设备状态推送时云端根据设备绑定的当前订单找到userId再找到对应的WebSocket连接发送消息。有个值得借鉴的细节WebSocket发送消息失败时用户切后台导致连接断开云端不会重试而是依赖小程序端的HTTP轮询兜底。小程序每10秒请求一次订单详情刷新最新状态。这种双通道冗余的设计思路在很多IoT应用里都适用——实时通道保证体验轮询通道保证可靠性。3. 核心难点与踩坑记录3.1 设备断网续传与离线补单宠物洗澡机最容易出问题的场景就是断网。机器在商场里运营商的4G信号受商场内部结构影响很大设备端上报到一半断网或者断网期间用户洗到一半这些情况都得兜住。源码里针对上报数据做了本地存储。设备端SD卡/RTC内存里维护一个环形队列存最近2小时的上报数据。断网重连后设备端先把队列里的数据补发到云端再进入正常上报节奏。// 云端处理补偿上报按时间戳去重 public void handleTelemetry(DeviceTelemetry telemetry) { String key telemetry:dedup: telemetry.getDeviceId() : telemetry.getTimestamp(); Boolean firstSeen redis.setIfAbsent(key, 1, Duration.ofHours(2)); if (Boolean.TRUE.equals(firstSeen)) { // 首次收到的数据进入正常处理流程 processTelemetry(telemetry); } // 重复数据直接丢弃 }按时间戳去重这个思路值得记下来。IoT场景消息可能重复投递业务处理时必须幂等。这里直接用Redis的SETNX命令2小时内重复消息自动丢弃成本低效率高。离线补单解决的是更严重的情况设备断网前订单处于WASHING状态但断网期间设备还在工作中云端无法收到结束信号。源码里设计了一个定时补偿任务每分钟扫描一次处于WASHING状态的订单检查设备最后的通信时间。如果设备离线超过30分钟自动将订单置为COMPLETED按设备端最后上报的进度计算费用并向用户推送设备离线订单已自动结算的通知。这种设计不是完美的但它是工程上的现实取舍。相比让用户干等或者让商家亏钱自动结算并按预估费用扣款配合用户申诉退款机制是最平衡方案。3.2 同一台设备被两个人同时扫码这个场景在小程序上线初期几乎必现。两个人同时扫了同一台设备的二维码都创建了订单都试图控制设备。源码解决这个问题的思路是Redis分布式锁。扫码创建订单后不是直接创单成功而是先尝试对设备加锁public OrderEntity createOrder(String deviceId, String userId) { String lockKey device:lock: deviceId; String lockValue UUID.randomUUID().toString(); Boolean locked redis.setIfAbsent(lockKey, lockValue, Duration.ofMinutes(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(设备正在使用中请更换设备); } // 成功加锁后创单 OrderEntity order buildOrder(deviceId, userId); orderMapper.insert(order); return order; }锁的持有期是整个订单生命周期。订单完成后释放锁设备空闲。中途用户取消订单锁也要释放。释放锁时不能直接delete必须先比较锁的value是不是自己持有防止误删其他用户刚持有的锁。这里用了Lua脚本来保证原子性。if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个问题的本质是并发资源争用解决思路可以复用。任何一套IoT设备共享租用系统都要考虑同一物理资源被多次分配的可能性。3.3 传感器数据不准水位误判与温度波动设备端的水位传感器、温度传感器数据直接决定设备能否启动。水位没到设定值就启动水泵会烧坏水泵温度超过安全值还继续加热可能引发安全事故。源码里没有直接用原始传感器数值而是加了一层数据滤波。水温传感器采用滑动窗口均值滤波每5秒采集一次取最近3次数据的平均值作为当前温度水位传感器采用迟滞比较水位上升和下降使用不同的判定阈值避免临界抖动导致水泵频繁启停。public double getFilteredTemperature(double rawTemp) { // 环形缓冲区保存最近3个样本 temperatureBuffer.add(rawTemp); if (temperatureBuffer.size() 3) { return rawTemp; // 样本不足先返回原始值 } double avg temperatureBuffer.stream() .mapToDouble(Double::doubleValue).average() .orElse(rawTemp); return avg; }这种处理在工业控制里非常基础但在消费级IoT源码中经常被忽略。很多设备莫名其妙报故障码排查到最后发现是传感器数值抖动导致逻辑误判。加一层滤波故障率能降不少。另外设备端还实现了看门狗机制。如果主控程序卡死看门狗会强制重启设备。这个机制属于嵌入式开发的标配但源码里在重启前会发一条设备自恢复的消息给云端让云端把设备状态重置为空闲避免设备实际已经恢复正常云端却还标记为洗浴中。3.4 安全设计防伪造指令与支付一致IoT设备最大的安全隐患是伪造指令。如果攻击者知道设备的Topic和消息格式直接发一条解锁指令门锁就开了后果不堪设想。源码里的防护措施分三层。第一层设备连接MQTT Broker时必须携带TokenToken由云端根据设备密钥动态生成每24小时刷新一次。第二层所有Topic禁用了匿名访问设备只能订阅自己的Topic不能跨设备订阅。第三层设备端收到指令后会校验指令里的签名签名由设备端和云端共享的密钥用HMAC-SHA256生成保证指令来自云端而不是第三方。支付安全同样重要。预授权冻结、扣款、退款这3个环节必须通过服务端到服务端的方式与支付渠道交互小程序端只能发起请求不能直接操作资金。支付回调的处理也做了幂等设计同一个支付结果回调多次不会导致重复扣款。public void handlePayCallback(PayNotify notify) { String key pay:notify: notify.getTransactionId(); Boolean first redis.setIfAbsent(key, 1, Duration.ofDays(1)); if (!Boolean.TRUE.equals(first)) { log.info(重复的支付回调直接忽略transactionId{}, notify.getTransactionId()); return; } // 处理回调逻辑 processPayCallback(notify); }4. 源码结构速览与二次开发指南4.1 代码目录与模块划分拿到源码后不要急着逐行读代码先把项目结构摸清楚。项目的Maven模块划分如下petwash-iot ├── petwash-common // 通用工具类、常量定义、统一返回体 ├── petwash-device // 设备接入模块MQTT Broker、设备注册、指令下发 ├── petwash-order // 订单模块状态机、计费、支付对接 ├── petwash-admin // 商家后台模块设备管理、运营报表 ├── petwash-app // 小程序后端服务REST API、WebSocket推送 └── petwash-job // 定时任务模块离线补单、对账、影子同步模块划分的参考价值在于业务边界清晰。设备接入了petwash-device订单和钱相关的全在petwash-order小程序HTTP接口在petwash-app。新增一个功能时改动范围能控制在单个模块内不会牵一发动全身。4.2 核心调用链快速阅读如果时间有限建议先跟着一条创建订单 - 设备解锁 - 开始洗浴 - 结束扣款的主链路读代码把这条链路的类都过一遍整个系统的运行方式就清晰了。小程序扫码 - AppletAuthController.createOrder - OrderService.createOrder加Redis设备锁创建订单 - MqttGateway.publishCommand下发解锁指令 - DeviceTelemetryHandler.handleTelemetry设备上报运行状态 - OrderService.updateProgress更新洗浴进度 - UserWsSessionManager.sendProgress推送给小程序 - OfferService.finishOrder洗浴结束 - PaymentService.deductAndSettle扣款结算每条链路的入口都是Controller追踪到Service再到Mapper和外部服务调用读完一条主链路大概3小时能对整个项目有全局认识。如果目标是二次开发不需要把定时任务、对账之类的边缘模块一次看完。4.3 如何改造成自己的项目拿到源码最常做的是两类改造换设备硬件或者换业务场景。换硬件的话关键是适配设备端的协议对接层。源码在petwash-device里定义了一组设备SDK接口比如DeviceStateReporter、CommandExecutor、DeviceConfigFetcher。无论你用ESP8266、树莓派还是其他单片机只要按这套接口实现设备端的消息编解码和指令处理云端代码基本不用动。换业务场景更有意思。比如想把宠物洗澡机换成自助洗衣房系统核心逻辑完全复用同样的扫码开锁、按分钟计费、设备影子同步。你需要改的主要是计费价格配置、设备控制指令集、小程序端的流程文案。源码里把价格配置放在了数据库字典表指令集封装在device/command模块改造起来相对省事。我试着在这个项目基础上模拟了一个自助咖啡机管理后台的雏形把设备状态从洗浴中改成制作中把计费改成按杯计费大约花了两个工作日完成了核心流程的改造。这个项目的代码质量在中等偏上没有特别炫技的地方但命名规范、模块划分、异常处理都比较扎实适合作为学习Spring Boot物联网项目的入门素材。5. 常见问题排查实录整理几个实际运行过程中高频出现的问题每个都是可以直接对照排查的参考。症状可能原因排查思路解决方案设备频繁掉线4G信号弱、路由器连接数限制看设备端日志排查掉线前的网络状态增加心跳超时时间部署信号增强器改Wi-Fi为4G备份链路计费金额与预期不符暂停时长未扣除检查订单的pauseDurationSeconds字段值修复暂停恢复时累加逻辑给计费函数补充单元测试设备状态长时间不同步设备离线但云端未感知查看遗嘱消息是否正常触发检查遗嘱Topic配置确认Broker是否开启遗嘱转发支付回调延迟支付渠道服务异常查看回调请求日志确认是否收到回调增加回调主动查询任务超时后向支付渠道发对账请求小程序收不到设备状态WebSocket连接失效检查云端推送日志能否找到对应userId的连接补充兜底轮询每10秒刷新一次订单详情同一设备被重复下单Redis锁未释放检查订单状态异常结束时是否释放锁在事务的finally块里确保释放锁5.1 MQTT断线重连的踩坑最常见的问题是设备端MQTT断线后不会自动重连或者重连后订阅关系丢失。源码里设备端用了MqttCallback的重连机制在connectionLost方法里实现指数退避重连。public void connectionLost(Throwable cause) { int retryCount 0; while (retryCount 5 !mqttClient.isConnected()) { try { Thread.sleep(1000L * (retryCount 1)); mqttClient.connect(); } catch (Exception e) { retryCount; } } if (!mqttClient.isConnected()) { // 重连失败进入深度休眠等待硬件看门狗重启 deviceManager.enterSleepMode(); } }注意这里重连是同步等待的如果5次都失败不再继续自杀式重试而是进入休眠模式等看门狗重启整个设备。这种处理比无限重连要合理避免设备在弱网环境下持续高功耗重试导致电池耗尽或设备过热。5.2 计费异常暂停时长被计费有个上线后遇到的问题用户中途开门调整宠物小程序上点了暂停设备确实停了但发现暂停时间最后还是被计费了。查到最后是pauseDurationSeconds的累加逻辑没有加事务保护暂停恢复的时刻恰好和定时任务扫描时刻撞上导致字段更新丢失。解决方案是在暂停和恢复的接口上加Transactional同时用乐观锁version字段控制并发更新。这类问题在传统的Web开发里不罕见但在IoT系统里频繁发生因为状态变更不仅来自用户操作还来自设备上报多个触发源叠加就容易出现并发写冲突。5.3 设备状态不同步的救火经验有一次商家反馈设备已经空闲小程序上却显示洗浴中导致新用户无法下单。排查发现设备确实正常断电关机了但因为设备是直接物理断电比如商场晚上强制拉闸遗嘱消息没来得及发送云端一直认为设备在线。这里有两个修复点。第一个是设备端在断电前如果还能运行主动发离线消息第二个更重要——云端加了一个定时任务每5分钟扫描一次所有设备检查设备状态和订单状态是否匹配如果设备处于WASHING状态但超过10分钟没有收到遥测数据就自动把设备标记为疑似离线并把订单置为异常结算。这个自动纠错机制比单靠遗嘱消息更可靠。6. 二次开发的扩展思路6.1 从单机设备走向多设备联动源码只支持单台设备独立运行如果业务发展需要做多设备联动比如一个门店有多台洗澡机用户扫一次码可以预约任意空闲设备推荐的方向是引入设备组和调度模块。设备组概念每台设备归属于一个门店门店有多台设备。用户在门店维度发起预约后台按空闲优先、故障剔除的策略分配设备。设备分配后生成订单再走原来的流程。核心改动在createOrder环节把根据deviceId直接加锁改成在门店设备池里选一台好用的加锁。另外多门店场景还需要增加设备维度的数据看板。源码里虽然有基础的设备状态查询但没有做日活、单均时长、设备利用率这些指标计算。二次开发时可以用定时任务把订单表数据聚合成运营报表或者引入一个轻量级的数据统计组件。6.2 从被动控制走向主动运维当前架构属于设备上报 云端处理的被动模式。再进一步可以做主动运维设备端增加温湿度、振动等传感器云端通过算法预判设备故障风险。比如设备运行过程中振动频率异常大概率是轴承磨损或者水泵堵塞云端提前生成维修工单而不是等设备彻底罢工后才报警。实现上不复杂——在设备端遥测数据里增加振动频率字段云端用规则引擎处理规则可以简单到连续10次遥测振动值超过阈值命中规则就触发告警。这是从事后处理走向事前预测的第一步投入不大但体验提升明显。6.3 源码学习重点总结如果是从0开始学这个项目源码我建议按这个顺序先读懂设备接入模块理解MQTT的使用方式和Topic设计再读订单状态机理解业务流转设计然后读设备影子实现理解离线指令处理机制最后看小程序后端服务理解WebSocket推送和REST API的协同不必一开始就陷在定时任务和支付细节里。先把主链路跑通再逐步扩展理解效率会高很多。源码里有一点值得反复看——各种兜底设计消息去重、设备影子、离线补单、异常状态纠错。这些兜底逻辑是物联网系统区别于普通Web系统的关键也是实际运行中真正保命的代码。说到这我想起来这个项目里有一个设计让我印象最深设备端上报数据里的sequenceId字段。每一条上报消息都带着一个单调递增的序号云端根据序号判断数据是否连续。如果发现序号跳跃说明中间有消息丢失云端会主动请求设备补发。这个细节解决了一个很隐蔽的问题——设备上报快、云端处理慢时消息在网络上积压如果不按序号去重和补发数据会出现乱序或空洞。很多生产环境的问题都出在这种看起来不必要的机制上做IoT系统,这类防御性设计的价值,只有真正出过事的人才懂。如果你准备基于这套源码做自研我最后补一句架构可以抄但设备端的硬件适配一定要肯花时间测试。软件逻辑再完善设备端的传感器采集中断、继电器动作延迟、电源波动导致的异常重启都会在真实环境中冒出来。仿真测试做得再好也比不上在样机上跑3天3夜的持续稳定性验证。