近两年无人共享空间在国内铺开得很快,棋牌室、茶室、台球室纷纷往这个方向转型。我去年帮一位做棋牌运营的朋友完整搭过一套无人值守管理系统,后端用Java(Spring Boot)实现,覆盖线上预约、扫码开锁、计时计费、设备联动和异常告警,前端配的是微信小程序。项目上线后,原本需要两班倒的前台岗位缩减为一位兼职保洁,夜间订单却明显上涨了不少。今天把整个项目的设计思路、核心实现和踩坑记录整理出来,给准备接同类项目的朋友做个参考。
这套系统适合谁?如果你是做智能门店SaaS的开发者,或是传统棋牌室、茶室、台球室的经营者想了解无人化改造的技术路径,又或者是刚接触IoT设备集成的Java后端工程师,这篇文章都能帮你省掉不少探路时间。内容偏后端实现,前端部分只会简单带过硬件的交互逻辑。
1. 项目整体设计与思路拆解
1.1 无人共享场景的运营痛点与解决思路
棋牌室、茶室、台球室这类场地有几个共性特点:使用高峰集中在晚上和周末,凌晨依然有散客需求,但雇人值班成本高;预订方式传统,顾客到店后发现没包间只能白跑一趟;结账靠人工计时,经常出现时长争议。
无人化改造的核心目标不是完全砍掉人,而是把“前台”这个角色用系统替代。顾客线上选房、支付押金、到店扫码、自动计费、离店自动结算,整个链路不需要人与人对接。商家端只需要看后台数据,处理异常订单和保洁安排。
我在设计系统时把用户流程简化成了五步:
- 小程序浏览门店和房型,选择时段下单。
- 支付订单费用和押金(新用户收押金,老用户免押)。
- 到店后小程序点击开门,门锁打开同时灯光空调自动开启。
- 使用过程中系统实时计时,后台记录入场时间。
- 离店时点击结束,系统自动计算费用,扣除时长费,原路退回押金。
这几步看起来简单,落地时背后涉及的模块可不少:订单状态管理、支付回调处理、设备通信、计费引擎、异常超时处理。任何一个环节出问题,用户都会被卡在线下,体验比传统前台还要差。
1.2 系统架构与技术选型思路
技术栈选定之前,我对比过Python、Go和Java三个方向。最终选了Java(Spring Boot),主要原因是支付和硬件SDK生态成熟。微信支付、支付宝的官方Java SDK维护稳定,智能锁厂商的对接文档也大多提供Java示例;另外团队本身对Java更熟,出了问题能快速定位。
系统整体分四层:
- 接入层:微信小程序、商家管理后台(Web端)、运维监控端。
- 业务服务层:用户会员、门店房间、订单计费、支付退款、设备管理、消息通知六大模块。
- 设备通信层:基于MQTT协议对接智能门锁、灯光控制器、空调网关、人体传感器。
- 数据层:MySQL存业务数据,Redis做热点缓存、分布式锁和实时计时状态。
有人可能会问,为什么不直接用HTTP轮询设备状态?这里解释一下:门锁需要接收服务端下发的开门指令,也需要把开门成功、门未关好等事件实时上报。HTTP轮询延迟高,而且设备侧实现复杂。MQTT基于长连接,服务端可以主动推送指令,设备也能主动上报事件,双向通信都顺畅。后续接入更多传感器也只是新增主题,不需要改动链路结构。
1.3 硬件方案与通信协议选型
硬件是整个无人共享系统里最容易翻车的部分。我调研过的门锁方案有三种:
| 方案类型 | 成本 | 稳定性 | 适用场景 |
|---|---|---|---|
| 蓝牙锁 + 网关 | 较低 | 中等 | 多包间、集中场地,网关统一转换信号 |
| 4G智能锁 | 中等 | 较高 | 包间分散、无法布网的场地 |
| 电控锁 + 门禁控制器 | 较高 | 高 | 标准门体、对安全要求高的场所 |
我最后选了蓝牙锁+网关方案。每个包间装蓝牙锁,包间门口部署一个网关,网关通过Wi-Fi连接公网,同时通过蓝牙管理附近几把锁。用户在门口点击开门,服务端经MQTT把指令推给网关,网关再通过蓝牙唤醒对应门锁完成开锁动作。
通信协议直接采用MQTT,消息QoS设成至少一次(QoS=1)。这样即使网络抖动导致ACK丢失,消息也会重发,配合服务端做幂等处理,基本不会出现指令丢失的问题。
MQTT主题设计也很关键,我按设备维度和业务维度分别规划:
- 下行指令:
/command/device/{deviceId},服务端推送开门、开灯、查询状态等指令。 - 上行事件:
/event/device/{deviceId},设备上报门锁状态、电量、人体感应等事件。 - 网关状态:
/status/gateway/{gatewayId},网关上下线、蓝牙连接质量。
这套主题结构后来扩展烟雾报警器、空调控制器时都没怎么改动,只补充了事件类型枚举。
2. 核心业务模块设计与实现要点
2.1 预约订单的状态机设计
无人场景下,用户的操作路径和系统后台任务会并发出现,订单状态必须清晰可控。我最初图省事在业务代码里到处更新状态,上线前联调时就出了乱子:用户支付成功后订单还在“待支付”,用户点击开门时订单还在“已预约”,代码里一堆状态判断互相矛盾。
后来老老实实引入了状态机,把订单状态收敛为六个:
- WAIT_PAY:待支付,下单成功但未付款。
- RESERVED:已预约,支付成功,等待用户到店。
- IN_USE:使用中,用户扫码开门成功,开始计时。
- COMPLETED:已完成,用户确认离场并完成结算。
- CANCELLED:已取消,超时未支付或用户主动取消。
- REFUNDED:已退款,押金或预授权退回。
状态流转规则:
- WAIT_PAY -> RESERVED:支付回调成功触发。
- WAIT_PAY -> CANCELLED:15分钟未支付,定时任务自动取消。
- RESERVED -> IN_USE:用户扫码开门,锁反馈开门成功。
- RESERVED -> CANCELLED:预约时段前用户申请取消,按规则退全款。
- IN_USE -> COMPLETED:用户点击结束并结算完成。
- COMPLETED -> REFUNDED:系统原路退回押金。
所有状态变更统一走一个状态机服务类,变更前校验前置状态,变更时记录状态流转日志,变更后发布Spring事件异步处理通知。这样即使以后增加新状态,也只需要改状态机配置,不用满项目找setStatus。
2.2 动态计费与智能结算
计费是无人共享系统最敏感的模块。定价策略本身由商家配置,但系统必须灵活支撑不同规则。
我设计的价格模型支持三套组合规则:
- 基础定价:工作日和节假日的每小时单价不同。
- 时段加价:晚高峰(比如18点到24点)按比例上浮。
- 宽限免费期:入场后前15分钟内取消订单不算费用,避免顾客临时反悔。
计费精度我采用的是“分钟累计、按小时结算”:实际使用时长按分钟记录,不满一小时的部分按一小时计算。这个规则必须在前端订单详情页明确提示,否则用户会对照系统计费和自己手动计时产生争议。
结算流程拆成三步:
- 入场时把入场时间写入Redis,键为
order:start:{orderId},同时更新订单状态为使用中。 - 用户点击结束时,取Redis中的入场时间,计算本次使用分钟数。
- 计费引擎结合门店价格策略,算出基础费用和时段加价,生成结算单。
押金处理也做了分级:新用户收取固定押金(100元),有过履约记录的用户免押金。信用评估当时直接接的支付分接口,简单可靠;如果项目预算有限,也可以自己维护用户信用积分,达到一定分值就自动免除。
2.3 门禁与设备联动逻辑
门禁不只是“开门”这一个动作,而是一串联动:
- 用户在小程序点击“开门”。
- 后端校验订单有效性和当前时间是否在预约时段内。
- 通过MQTT下发开门指令到对应网关。
- 门锁开锁并反馈开门成功事件。
- 服务端收到事件后更新订单状态为使用中,同时下发开灯指令。
- 用户结束订单后,服务端自动下发关灯、断电指令,并解除门锁密码授权。
这里有个容易忽略的细节:开门和状态更新必须保证一致性。如果门锁报告开门成功,但服务端更新状态失败,用户会被关在门外或出现无订单占用房间的情况。所以我在处理设备上行事件时加了补偿逻辑:收到开门成功事件后尝试更新订单状态,如果更新失败就发消息重试,同时记录告警日志。
此外,门锁密码授权也要设计好生命周期。用户预约成功后,系统向门锁下发一个临时密码,用户也可以通过小程序查看密码输锁开门;订单结束后,这个密码立即失效。这样即使网络故障导致MQTT指令无法送达,用户依然能进门,线下兜底能力就有了。
3. 核心代码实现与实操过程
3.1 基于Spring Boot搭建项目骨架
项目采用的是标准的分模块结构,避免所有业务堆在一个application里后期崩溃:
share-spot-server/ ├── pom.xml ├── src/main/java/com/example/sharedspot/ │ ├── controller/ // REST接口层 │ ├── service/ // 业务逻辑层 │ ├── repository/ // MyBatis-Plus数据访问层 │ ├── mqtt/ // MQTT客户端与消息处理 │ ├── config/ // Redisson、支付、线程池等配置 │ ├── dto/ // 请求响应对象 │ ├── entity/ // 数据库实体 │ └── Task/ // 定时任务核心依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.eclipse.paho</groupId> <artifactId>org.eclipse.paho.client.mqttv3</artifactId> </dependency>application.yml里主要配置数据源、Redis和MQTT连接参数:
spring: datasource: url: jdbc:mysql://localhost:3306/shared_spot?useSSL=false&serverTimezone=Asia/Shanghai username: root password: change_me data: redis: host: localhost port: 6379 mqtt: host: tcp://mqtt.example.com:1883 client-id: spot-server username: device password: change_me topic-prefix: /command/device3.2 计时计费引擎实现
计费引擎是核心中的核心,我把价格判断拆成两个维度:时间维度和门店定价配置。这样商家改价格不用发版重启。
@Service public class BillingService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private OrderMapper orderMapper; public BillingResult settleOrder(Long orderId) { // 幂等校验:订单是否已结算 OrderEntity order = orderMapper.selectById(orderId); if (order.getStatus() != OrderStatus.IN_USE.getCode()) { throw new BusinessException("订单当前状态不可结算"); } // 获取入场时间 String startKey = "order:start:" + orderId; String startTimeStr = redisTemplate.opsForValue().get(startKey); if (startTimeStr == null) { // 兜底:从数据库取入场时间 startTimeStr = String.valueOf(order.getStartTime().getTime()); } long now = System.currentTimeMillis(); long start = Long.parseLong(startTimeStr); int minutes = (int) ((now - start) / 60000); // 计算费用 ShopConfig config = shopConfigCache.get(order.getShopId()); BigDecimal unitPrice = config.getHourlyPrice(); int hours = (minutes + 59) / 60; // 不满一小时按一小时 BigDecimal baseFee = unitPrice.multiply(BigDecimal.valueOf(hours)); // 高峰期加价 LocalDateTime nowTime = LocalDateTime.now(); boolean peak = isPeakHour(nowTime, config.getPeakStart(), config.getPeakEnd()); BigDecimal additional = peak ? config.getPeakAddPrice().multiply(BigDecimal.valueOf(hours)) : BigDecimal.ZERO; BigDecimal totalFee = baseFee.add(additional); // 生成结算单 BillingResult result = new BillingResult(); result.setOrderId(orderId); result.setTotalMinutes(minutes); result.setChargedHours(hours); result.setTotalFee(totalFee); return result; } private boolean isPeakHour(LocalDateTime time, int peakStart, int peakEnd) { int hour = time.getHour(); return hour >= peakStart && hour < peakEnd; } }这套实现有几个关键点:
- 幂等校验放最前面:重复点击结束不会重复扣费。
- Redis兜底策略:如果Redis数据丢失,就从订单表的入场时间字段取,保证计费不归零。
- BigDecimal计钱:严禁用double或float计算金额,否则会出现0.1+0.2精度问题,财务上没法交代。
3.3 MQTT设备通信层实现
MQTT客户端我封装成一个独立的组件,启动时连接Broker,订阅所有设备上行事件主题。
@Component public class MqttGateway { private MqttClient client; @Value("${mqtt.host}") private String host; @Value("${mqtt.client-id}") private String clientId; @PostConstruct public void init() throws MqttException { client = new MqttClient(host, clientId, new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(false); options.setAutomaticReconnect(true); options.setConnectionTimeout(10); options.setKeepAliveInterval(30); options.setUserName(mqttUsername); options.setPassword(mqttPassword.toCharArray()); client.connect(options); // 订阅上行事件 client.subscribe("/event/#", 1); client.setCallback(new MqttCallback() { @Override public void connectionLost(Throwable cause) { // 记录日志,自动重连由setAutomaticReconnect处理 log.error("MQTT连接丢失", cause); } @Override public void messageArrived(String topic, MqttMessage message) { dispatchMessage(topic, new String(message.getPayload())); } @Override public void deliveryComplete(IMqttDeliveryToken token) { // 下行指令已送达 } }); } public void publishCommand(String deviceId, String payload) { String topic = "/command/device/" + deviceId; MqttMessage message = new MqttMessage(payload.getBytes()); message.setQos(1); // 记录待确认指令,设备上线后未确认则重发 pendingCommandService.save(deviceId, payload); client.publish(topic, message); } }消息到达后的分发逻辑用一个简单的策略模式:
public void dispatchMessage(String topic, String payload) { String deviceId = parseDeviceIdFromTopic(topic); DeviceEvent event = JSON.parseObject(payload, DeviceEvent.class); switch (event.getType()) { case DOOR_OPENED: deviceService.onDoorOpened(deviceId); break; case DOOR_CLOSED: deviceService.onDoorClosed(deviceId); break; case LOW_BATTERY: alertService.sendBatteryAlert(deviceId); break; default: break; } }这里有个实际问题要提醒:设备上行事件中,deviceId和orderId不一定总在同一个消息里。我遇到的情况是部分门锁上报的事件只带设备编码,不带任何业务信息。务必在服务端维护一张“设备绑定关系表”,把设备编码与当前订单关联起来,消息到达时先查表再更新业务状态。
3.4 分布式锁在开门场景中的运用
开门这个动作看似简单,其实存在并发隐患。用户在小程序连续点击“开门”两次,或者用户点击开门的同时,后台定时任务在检测到订单超期后触发了自动释放房间。两个请求同时操作同一个订单,一个要开门使用,一个要结算释放,就会乱套。
解决办法是对订单加分布式锁:
@Autowired private RedissonClient redissonClient; public ApiResponse openDoor(Long orderId) { RLock lock = redissonClient.getLock("order:open:" + orderId); boolean locked = false; try { locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { return ApiResponse.error("操作过于频繁,请稍后重试"); } // 校验订单状态是否处于可开门状态 OrderEntity order = orderMapper.selectById(orderId); if (order.getStatus() != OrderStatus.RESERVED.getCode() && order.getStatus() != OrderStatus.IN_USE.getCode()) { return ApiResponse.error("订单状态不可开门"); } // 下发开门指令 DeviceOrderRelation relation = deviceRelationMapper.findByOrderId(orderId); mqttGateway.publishCommand(relation.getDeviceId(), buildOpenDoorCommand(orderId)); // 乐观处理:预估开门需要2-5秒,记录操作时间 return ApiResponse.success(); } finally { if (locked) { lock.unlock(); } } }除了Redisson分布式锁,我还在数据库层面给订单表加了唯一约束作为兜底,防止极端情况下同一订单被重复结算。代码里每个写操作都是幂等的:加Redis键时用setIfAbsent,重复请求直接返回。
4. 常见问题与排查技巧实录
4.1 订单超时不释放导致房间被占用
项目上线初期遇到一个典型的运营问题:用户预约的是2小时,到点后没有主动结束订单,系统如果直接释放房间,用户在包间里躺着不走,下一位顾客到店后扫码发现房间已经开门,体验极差。
我当时的处理方案是加了一个“超时缓冲”策略:
- 预约结束前15分钟,小程序推送提醒,问用户是否需要续时。
- 超时15分钟后,系统自动发起续时,按当前时段单价继续计费。
- 超过30分钟后,如果后台检测到房间内无人(人体传感器无活动),才会触发强制释放。
如果强制释放时房间内还有人,系统会再次尝试推送提醒,并且给商家发送告警通知,由商家电话沟通,避免激化矛盾。无人共享运营不能一味追求自动化,该让人出场干预的时候必须让人出场。
4.2 设备离线与消息丢失处理
MQTT连接中断是出现频率最高的问题。门锁离线时,开门指令发的再多也没用。我在上线前盯着这个问题做了好几轮优化:
- 自动重连:
setAutomaticReconnect(true)保证MQTT客户端掉线后自动恢复连接。 - 待确认指令表:服务端每发一条关键指令,先写入MySQL表,状态为SENT,收到设备ACK后更新为CONFIRMED。定时任务扫描超过30秒未确认的指令,会在设备重新上线后补发。
- 本地密码兜底:每笔订单生成一个随机临时密码,同步到门锁本地存储。即使网络完全瘫痪,用户也能通过输入本地密码开门。这个设计在真实故障时救过好几次场。
设备离线期间,用户端小程序要给出友好提示,不能只显示一片空白。我在小程序扫码和开门按钮上都加了设备在线状态预检查,设备离线时明确提示“当前设备离线,请使用密码进门”,用户反馈明显好很多。
4.3 并发扣款与重复结算问题
早期的Bug:用户点击“结束并结算”的瞬间,订单超时自动结算任务也触发了,两个线程同时执行结算逻辑,造成用户被扣两次费用。这类问题排查起来很隐蔽,因为不是必现,只在网络卡顿或者用户连续点击时出现。
后来做了三层防护:
- 状态机前置校验:结算前必须确认订单状态是IN_USE,改成COMPLETED时用条件更新SQL,
UPDATE order SET status = 'COMPLETED' WHERE id = ? AND status = 'IN_USE',影响行数为0说明已被别人处理。 - 分布式锁:结算和开门都走锁,同一订单的并发操作全部串行化。
- 幂等表:结算记录表增加
order_id唯一索引,重复插入直接报错。
这样即使上游代码逻辑有遗漏,数据库也会挡住错误数据。
4.4 计费边界与异常订单处理
计费发生过一次用户投诉:用户实际用了1小时5分钟,前端显示“按时段计费,不足1小时按1小时”,但结算单却按2小时收。排查发现是后端把分钟转小时时用了(minutes + 59) / 60向上取整,1小时5分钟确实变成了2小时。问题出在规则表达不清晰:有的商家希望不足一小时按一小时,有的商家希望按分钟精确计费。
最终我把计费模式做成可配置的:
| 模式 | 规则说明 | 适用场景 |
|---|---|---|
| 向上取整 | 1分钟也算1小时 | 传统棋牌室习惯 |
| 分钟精确 | 按实际分钟换算金额 | 茶室、自习室等灵活场景 |
前端在下单页根据门店配置动态展示计费规则,后端结算时读取同一份配置,避免前后端口径不一致。
异常订单处理上,我额外开发了商家后台的“订单修正”功能:运营人员可以手动调整时长、补录费用、发起退款。无人系统再智能,也要留人工干预的口子。
个人实操体会与扩展建议
整套系统从设计到上线,我花了大概三个月,中途推翻重来两次,一次是订单状态机,一次是MQTT消息可靠性设计。回头看,无人共享系统的难点不在Java代码本身,而在物理设备的不可控性上。软件可以靠单元测试保障,硬件却要面对断网、断电、门锁低电量这些现实问题。我的体会是,硬件稳定性优先级必须高于功能丰富度,一个房间接入的设备越少越可靠,功能可以后续慢慢加,但门打不开或者结算错误只需要一次,就会让用户彻底失去信任。
后续如果要扩展,这几个方向是可以直接往上叠加的:会员储值卡与套餐包、分时营销优惠、多门店连锁管理、经营数据大屏。系统架构在当初设计时已经预留了门店纬度,新增一个老板账号绑多门店就能支持连锁;设备层也预留了扩展topic,接入空调、灯光、新风系统只需增加事件类型枚举。
最后分享一个小技巧:接入任何新硬件时,先做一组破坏性测试,拔网线、断电、锁低电量、网关死机,逐个验证兜底逻辑是否生效。这些模拟故障的场景越早测,线上出问题的概率就越低。无人共享空间的核心是“无人”,但背后要有一整套应急预案,不是靠运气撑住的。