资讯动态

自助洗车三端合一系统Java源码实战:设备通信、订单状态机与余额并发扣减

发布时间:2026/10/18 17:39:10 来源:尧图企业网站定制
简介这是一套面向自助洗车行业开发者与创业团队的三端合一数字化运营源码包后端采用Spring BootJava构建RESTful接口前端覆盖Vue 2.x与Element UI的Web管理后台及原生微信小程序可支撑会员等级、车牌识别、洗车机状态监控、预约扫码启动、微信支付与订单统计等核心业务适合需要快速搭建洗车店管理系统的中高级开发者二次开发。资源包共1048个文件约14.38MB包含131个Java类、151个Vue组件、45组小程序页面文件wxml/wxss/js以及JSON、XML、properties等配置与245张PNG、161个SVG静态资源另附Maven wrapper与pom.xml等构建脚本本地部署调试较为便捷。目前已有26人学习下载。整体目录按后端、管理端、小程序分层组织硬件对接逻辑与支付流程均有对应实现可作为洗车物联网项目落地或课程设计的完整参考。1. 自助洗车店三端合一系统从扫码到结算一套 Java 源码怎么串起来自助洗车这个场景表面看是「扫码—付款—出水—结算」四步真做起来会发现它同时踩在物联网、支付、实时通信和门店运营四条线上。一台洗车机要能自己认设备、自己算钱、自己控水控电后台还得让老板看得懂每台机器今天赚了多少、哪个枪头被薅了羊毛。标题里的「三端合一」说的就是把用户小程序端、门店 Vue 管理端、以及跑在服务器上的 Java 后台放进同一套代码仓库里共用一套设备协议、一套订单模型、一套权限体系。它适合两类人一类是想自己搭一套洗车 SaaS 接门店的开发者另一类是门店老板想私有化部署、不想被第三方平台抽成。这套东西不是玩具 Demo设备在线率、订单幂等、余额并发扣减任何一个没处理好第二天就会有客户站在洗车机前骂人。2. 三端职责怎么切小程序、Vue 管理端、Java 后台的边界先把三端各自该干什么定死后面写代码才不会互相甩锅。很多翻车案例不是技术不行是一开始边界糊了——小程序直接连数据库、管理端自己算订单金额、后台只当个转发器最后三边数据对不上查一个问题要开三个终端。2.1 小程序端只做「发起」和「展示」不做「裁决」小程序端的核心职责只有三件事扫码识别设备、发起启动/停止指令、展示实时状态和账单。它不应该自己判断「余额够不够扣这次洗车」也不应该自己算「洗了 8 分钟该收多少钱」。原因很直接小程序运行在用户手机上时间可以被改、请求可以被重放、余额显示可以被缓存。凡是涉及钱和设备的判断全部丢给后台。常见做法是小程序通过 WebSocket 或轮询拿设备状态启动指令走 HTTPS 提交到后台后台再通过 MQTT 或 TCP 长连接下发给洗车机主控板。小程序拿到的是一个「指令已受理」的响应而不是「设备已启动」的确认——真正的启动确认由设备回传后后台再推给小程序。// 小程序端发起启动请求只传设备编号和支付方式不传金额 wx.request({ url: https://your-domain/api/device/start, method: POST, header: { Authorization: Bearer token }, data: { deviceNo: XC-001, // 设备编号扫码得到 payType: BALANCE, // BALANCE 余额 / WECHAT 微信 requestId: uuid() // 幂等键防止重复提交 }, success(res) { // 只处理受理结果不在这里判断能否启动 if (res.data.code 0) { wx.showToast({ title: 指令已下发 }) } } })这段代码的关键在requestId。用户手抖点两次、网络超时重发后台靠这个键做幂等同一requestId只受理一次。payType只传类型不传金额金额由后台根据设备当前费率计算小程序没有定价权。2.2 Vue 管理端门店运营的「驾驶舱」不碰设备协议管理端面向的是门店老板和店员它要解决的是「看得见、管得住、算得清」。典型页面包括设备列表与在线状态、订单流水、会员余额、套餐配置、营收报表。它不应该直接跟洗车机通信也不应该自己拼设备指令。管理端所有操作都通过后台 REST 接口完成后台再决定要不要下发设备指令。一个容易踩的坑是管理端做了「强制停止设备」按钮直接调设备接口。正确做法是管理端调后台的/api/admin/device/force-stop后台记录操作人、操作时间、原因再下发停止指令。这样出了纠纷后台有完整操作日志而不是「谁点的不知道」。2.3 Java 后台唯一的「裁判」管钱、管设备、管状态后台是整个系统的裁判。它要维护设备在线状态机、订单状态机、余额账户还要处理设备上报的实时数据。技术选型上常见组合是 Spring Boot MyBatis-Plus Redis MQTT 客户端 MySQL。Redis 用来做设备状态缓存和分布式锁MQTT 用来和设备保持长连接MySQL 存订单和账户流水。后台的核心不是 CRUD而是状态机。设备有「离线/空闲/使用中/故障」四个状态订单有「待支付/已支付/使用中/已完成/已退款」五个状态。每次状态流转都要校验前置状态比如「使用中」的订单不能再次发起启动「已完成」的订单不能重复退款。这套状态机如果只靠 if-else 散落在各个 Service 里三个月后没人敢改。3. 设备通信与订单状态机后台最容易被忽略的两块硬骨头这两块是整套系统的地基。设备通信决定「指令能不能到」订单状态机决定「钱算得对不对」。很多源码包把这两块写得含糊跑 Demo 没问题一上真实设备就露馅。3.1 用 MQTT 做设备长连接主题设计与心跳参数洗车机主控板通常资源有限跑不了 HTTP 长轮询MQTT 是最常见的选择。主题设计要遵循「上行下行分离、设备维度隔离」的原则。常见做法是方向主题格式说明上行device/{deviceNo}/report设备上报状态、用量、故障下行device/{deviceNo}/command后台下发启动、停止、重启心跳device/{deviceNo}/heartbeat设备每 30 秒发一次后台订阅device//report和device//heartbeat用通配符收所有设备消息。心跳超时判定离线常见阈值是 90 秒——连续三个心跳周期没收到就标记离线。这个值不能设太短现场 4G 信号抖动很常见设 30 秒会导致设备频繁上下线管理端看着一直闪。// Spring Boot 中 MQTT 客户端订阅上行主题 Bean public MqttClient mqttClient() throws MqttException { MqttClient client new MqttClient(tcp://broker:1883, server- UUID.randomUUID()); MqttConnectOptions options new MqttConnectOptions(); options.setAutomaticReconnect(true); options.setCleanSession(false); // 保留会话断线重连不丢消息 options.setKeepAliveInterval(60); // 服务端心跳 60 秒 client.connect(options); client.subscribe(device//report, (topic, msg) - { String deviceNo topic.split(/)[1]; deviceReportService.handle(deviceNo, new String(msg.getPayload())); }); client.subscribe(device//heartbeat, (topic, msg) - { String deviceNo topic.split(/)[1]; deviceStatusService.refreshHeartbeat(deviceNo); }); return client; }setCleanSession(false)是关键参数。设为 true 时设备断线重连后 broker 不保留离线消息后台可能漏掉设备上报的「已完成」状态导致订单一直卡在「使用中」。设为 false 后broker 会为客户端保留会话和未确认消息重连后补发。代价是 broker 内存占用会上升设备量大时要监控。3.2 订单状态机用枚举 校验表替代散落的 if-else订单状态流转必须集中管理。我一般会定义一个状态枚举和一个允许流转表任何状态变更都走同一个方法不通过就抛异常。public enum OrderStatus { PENDING_PAY, PAID, USING, FINISHED, REFUNDED } // 允许的流转key 是当前状态value 是可达状态集合 private static final MapOrderStatus, SetOrderStatus TRANSFER Map.of( OrderStatus.PENDING_PAY, Set.of(OrderStatus.PAID, OrderStatus.REFUNDED), OrderStatus.PAID, Set.of(OrderStatus.USING, OrderStatus.REFUNDED), OrderStatus.USING, Set.of(OrderStatus.FINISHED), OrderStatus.FINISHED, Set.of(), OrderStatus.REFUNDED, Set.of() ); public void transfer(Order order, OrderStatus target) { SetOrderStatus allowed TRANSFER.get(order.getStatus()); if (!allowed.contains(target)) { throw new BizException(非法状态流转: order.getStatus() - target); } order.setStatus(target); orderMapper.updateById(order); }这张表把「什么状态能到什么状态」写死了。比如FINISHED后面不允许再流转退款只能从PENDING_PAY、PAID走USING中的订单不能直接退款——必须先停止设备、结算完成再走退款。这样能挡住大部分「订单已完成又退了一次款」的脏操作。3.3 余额扣减Redis 分布式锁 数据库乐观锁双保险余额扣减是并发重灾区。用户余额 10 元同时发起两次启动请求如果只用数据库update balance balance - 5两次都成功余额变 0但实际只该扣一次。常见做法是 Redis 分布式锁锁住用户 ID再在数据库层用版本号做乐观锁。public void deduct(Long userId, BigDecimal amount) { String lockKey lock:balance: userId; // 尝试加锁等待 3 秒持有 10 秒自动释放 Boolean locked redis.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(locked)) { throw new BizException(操作过于频繁请稍后再试); } try { Account account accountMapper.selectById(userId); if (account.getBalance().compareTo(amount) 0) { throw new BizException(余额不足); } int rows accountMapper.deductWithVersion(userId, amount, account.getVersion()); if (rows 0) { throw new BizException(扣减冲突请重试); } } finally { redis.delete(lockKey); } }deductWithVersion对应的 SQL 是update account set balance balance - #{amount}, version version 1 where id #{userId} and version #{version} and balance #{amount}。锁保证同一用户串行版本号保证即使锁失效也不会扣成负数。两个都上是因为 Redis 锁在极端情况下可能因为主从切换丢失数据库乐观锁是最后一道防线。4. 避坑与排查三端联调时最容易翻车的五个点这一章全是血泪经验。三端联调阶段的问题往往不是某个端写错了而是端与端之间的约定没对齐。4.1 设备显示在线但指令下发无响应现象管理端看到设备在线点启动后小程序一直转圈设备没反应。原因通常是心跳和指令走了不同通道心跳正常但指令主题订阅失败。排查时先看 broker 的订阅列表确认后台是否真的订阅了device//command的发布权限再看设备端是否订阅了自己的 command 主题。解决方式是后台下发指令后记录一条「指令下发日志」包含主题、payload、时间设备回执后再记一条两条对不上就能定位是下发丢了还是回执丢了。4.2 订单金额和实际用水量对不上现象用户洗了 6 分钟订单显示扣了 8 分钟的钱。原因多半是设备上报的「结束时间」和后台记录的「启动时间」用了不同时钟源。设备本地时钟没校准或者上报的是相对时间。解决方式是后台以收到「启动确认」的时间为计费起点以收到「结束上报」的时间为终点设备上报的时长只做参考不做计费依据。同时要求设备每次心跳带上自己的时间戳后台比对偏差超过 30 秒就告警。4.3 小程序重复提交导致重复扣款现象用户点了一次启动扣了两次钱。原因是没有做幂等或者幂等键生成逻辑有问题——比如用时间戳做键两次点击时间戳不同。解决方式是幂等键由小程序在页面加载时生成一次 UUID整个启动流程复用同一个键后台用 Redis 存requestId - 处理结果重复请求直接返回第一次的结果。4.4 管理端报表数字和订单列表对不上现象报表显示今日营收 1000 元订单列表加起来只有 950 元。原因通常是报表用了缓存或定时任务聚合而订单列表是实时查库两者统计口径不一致。解决方式是报表也走实时聚合或者明确报表数据延迟不超过 5 分钟并在页面标注「数据截至 XX 时间」。不要一边用缓存一边用实时对不上时用户只信列表。4.5 设备离线后订单一直卡在「使用中」现象设备断网用户洗完走了订单永远停在「使用中」余额被冻结。原因是后台只依赖设备上报的「结束」事件来结束订单设备没上报就没人收尾。解决方式是加一个兜底定时任务扫描「使用中」且超过套餐最大时长比如 30 分钟的订单自动标记为「已完成」并按最大时长结算同时记录异常原因。这个兜底任务必须有否则每天都会积累一堆僵尸订单。5. 从能跑到能运营三端合一的部署与压测小技巧一套源码能不能真正接门店不看功能列表看两件事部署是否可复制、并发是否扛得住。我一般会在本地用 Docker Compose 把 MySQL、Redis、MQTT broker、后台、管理端前端一次拉起确认三端能通再上服务器。# docker-compose 片段一次拉起依赖服务 version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: carwash ports: [3306:3306] redis: image: redis:7 ports: [6379:6379] mqtt: image: eclipse-mosquitto:2 ports: [1883:1883, 9001:9001] volumes: - ./mosquitto.conf:/mosquitto/config/mosquitto.confmosquitto.conf里至少配listener 1883和allow_anonymous true内网测试用生产环境要开认证。后台的application.yml里把 broker 地址、数据库地址、Redis 地址都指向这些容器管理端前端改VUE_APP_BASE_API指向后台。三端跑通后用 JMeter 或 wrk 压一下/api/device/start重点看余额扣减在 200 并发下有没有超卖。我自己的习惯是压测前先把 Redis 锁的等待时间调到 1 秒压完再调回 3 秒——等待时间太长会把线程池打满太短又挡不住并发这个值要根据实际 QPS 调。还有一个容易被忽略的点设备指令下发要做限流。同一台设备 1 秒内收到多条启动指令主控板可能直接死机。后台在下发前用 Redis 对deviceNo做 1 秒 1 次的限流超出的指令直接拒绝并返回「操作过于频繁」。这个限流不是防用户是防程序 bug 和恶意重放。最后说一个验证方法拿一台真实洗车机或者模拟器连续跑 50 次「启动—用水—结束」循环每次结束后核对订单金额、余额、设备状态三者是否一致。跑完 50 次没有一笔对不上这套三端合一才算真正能接门店。我见过太多源码包功能演示很漂亮一跑循环就暴露状态机漏洞。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑