ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

无人茶室系统实战:Java Spring Boot预约与设备联动设计

无人茶室系统实战:Java Spring Boot预约与设备联动设计 把无人茶室这套系统从零到一落地前后大概用了三周。项目本身不复杂但“无人”两个字把所有压力都压在了后台——预约排期、订单计费、门锁联动、异常告警哪一个环节断了客人都会被关在门外。技术底座选了 Java原因很直接Spring Boot 生态成熟分布式锁、消息队列、WebSocket 这些组件都有现成方案团队也比较好招人。这篇内容把我踩过的坑、关键模块的设计思路和可复现的细节完整展开想搞类似业态的 Java 开发者可以直接参考。1. 我为什么用 Java 做无人茶室的底座1.1 无人茶室和普通餐饮系统最大的差别传统餐饮系统的核心是收银和堂食排队后厨和服务员能把很多异常兜住。无人茶室不一样门店里没有一个固定店员顾客从进店到离开完全自助系统必须把“门禁是否正常开启”“房间里的电有没有通”“超时了要不要自动加收”“水电异常谁来处理”这些问题全部接管。换句话说普通系统只需要管理业务数据无人茶室系统还要管理实体设备的状态业务链路从“用户下单”一直延伸到“物理世界执行”这是第一个设计上的分水岭。当时我梳理需求时有一个很深的感受预约模块不是 CRM 里的一个小功能而是整个系统的“排班大脑”。每个茶室就是一条可售资源什么时间空闲、什么时间被占、哪些时段可以连订、哪些时段要留出来做保洁全部要在这里算清楚。再往下是订单和计费最后是门锁、电表这类执行层。所以项目虽然叫“预约系统”实际是资源调度加 IoT 控制选型时不能只当普通 CRUD 来做。1.2 Java 生态能省下什么最终确定用 Java 17 Spring Boot 3.x持久层用 MyBatis-Plus缓存和分布式锁用 Redis异步消息用 RabbitMQ。这套组合在 Java 社区里非常常见闭着眼睛都能找到大量踩坑案例团队上手成本低后续扩展也方便。我之前也考虑过用 Node.js 或者 Go 做服务端但它们在这类业务里的“标准件”不如 Java 丰富尤其是支付回调、微信小程序接口、定时任务、规则引擎这些场景Java 的库和文档都更全。有人会觉得“Java 太重了”对一个茶室预约系统来说Spring Boot 单体部署大概占用 512MB 内存这个成本完全可以接受。真正重要的是它能提供什么spring-boot-starter-data-redis直接封装了分布式锁要用的客户端spring-boot-starter-amqp接入消息队列只要几行配置支付回调用RestController配合Transactional就能处理得很干净。对一个小团队来说稳定性和生态比“启动速度快 20ms”更有价值。2. 功能模块怎么拆哪些必须优先做2.1 预约排期核心是“可售时段”我先定义的模块是房间管理、时段模板、预约单、订单支付、会员储值、运营看板、设备控制这七个。里面真正决定系统复杂度的是时段模板和预约单。房间表很简单字段就是房名、面积、容纳人数、默认单价、状态。时段模板稍微要花点心思它决定了一个房间每天能被切成多少个可售时段。我的做法是按 30 分钟一个粒度生成时段同时允许运营后台配置“跨时段包场”。为什么不用更细的 15 分钟因为茶室消费客单价高、单次时长多在 2 小时以上30 分钟不会让顾客等太久又不会让 schedule 表膨胀到难以维护。生成时段的规则每天 00:00 到 23:30每间房生成 48 个基础时段如果商家配置了只营业到 22:00那就只生成前 44 个时段。这样用户端可以按半小时格子选后台计算价格也直观。2.2 订单计费押金、时长和超时怎么算无人场景最容易扯皮的两个点超时占用和房间损坏。我的方案是下单时必须选择“预计结束时间”系统按预计时长预授权或收取押金订单开始后每 15 分钟做一次当前时间比对如果已经超过预计结束时间就进入“超时中”状态按分钟单价加收同时给后台和自助小程序推送加收提示。这个逻辑放在订单状态机里而不是靠定时任务一分钟扫一次因为定时任务有执行延迟容易漏单。押金部分接的是微信支付“押金冻结/解冻”能力顾客在小程序里选择预约时段后先只冻结一部分金额订单结束正常履约再解冻并扣实际费用。如果顾客未按时离场超时费用从押金里扣余额不足时推送补足通知。这里要注意不是所有微信支付商户号都开通了押金接口签约前一定要跟服务商确认如果没开通就退回“先支付全额预计费用结束后原路退回差额”的方案逻辑会稍微复杂一点。2.3 运营后台和告警通道运营后台没有做太多花哨的东西核心是三类页面房间时段价格配置、订单明细和退款审核、异常告警中心。告警中心单独说一句我认为无人门店系统可以不要花里胡哨的大屏但不能没有异常通知。我现在接的是服务号模板消息和短信触发点有三个门锁连续三次开锁失败、订单超时超过 30 分钟未离场、设备心跳超过 5 分钟没上报。告警消息里必须带上门店名称、房间号、订单号、最近一次心跳时间否则值班人员接到消息根本没法判断严重性。3. 数据库设计先把资源状态和订单状态分开3.1 核心表结构数据库我建议直接用 MySQL 8.0InnoDB。核心表可以分成资源域、交易域、设备域三类。资源域是t_room、t_room_schedule、t_price_config交易域是t_reservation、t_order、t_member、t_payment_record设备域是t_device、t_device_callback_log。不要把 IoT 日志和业务表混在一起回调和设备上报频率高写业务表很容易把正常交易拖慢。t_room_schedule是我整张业务表里最重要的字段包括id, room_id, biz_date, start_time, end_time, status, current_reservation_id, version, create_time, update_time。这里的status只有 FREE 和 OCCUPIED 两个值不要加“预占”“锁定”太多状态预占用订单表的状态来表示。current_reservation_id指向当前占用这个时段的预约单这样同一时段可以产生多条历史预约流水但实时只认当前这个字段。3.2 为什么用版本号而不是纯状态判断一开始我写过“update schedule set statusOCCUPIED where statusFREE”这种 SQL线上并发不高时没问题一旦有两三个人同时抢最后剩下的一格就可能出现双双更新成功。因为 MySQL 默认的UPDATE ... WHERE是行锁第二个事务会等待但等第一个提交后如果没有匹配条件影响行数是 0程序拿到 0 却不知道是被谁抢了。所以我在 schedule 上加了version字段每次更新都带上旧的版本号影响行数为 1 才算成功否则就抛出“该时段已被预约”。current_reservation_id的作用是取消预约释放时段时重置 schedule 的这列和 status让时段的当前占用关系始终有一根明确的指针。不要在取消时删除预约流水否则后续对账和审计都查不到原始记录这是我在运营对账时踩过的坑。3.3 索引和唯一约束怎么加交易表里t_reservation至少需要这些索引(schedule_id)、(member_id, create_time)、(order_no)唯一索引(status, create_time)用于后台筛选异常单。t_order的order_no必须唯一支付回调按单号做幂等。t_payment_record以transaction_id做唯一索引防止微信支付回调重复入账。再加一个容易被忽略的点t_device_callback_log这种日志表不要建太多索引一般只留(device_id, create_time)就够了。门锁回调一天可能几十万条索引过多会让写入变慢查询时只保留近 30 天的数据定期归档到冷表。核心业务表反而要少连表查询把常用的聚合结果冗余到缓存里这是无人门店系统这种读写不均衡场景下比较务实的做法。4. 预约并发和数据一致性真正花时间的地方4.1 Redis 分布式锁的粒度预约系统并发峰值不高但“最后十分钟”的抢约场景确实存在。我的方案是以room:schedule:{scheduleId}为 key 加 Redis 锁锁的过期时间 10 秒业务操作必须在 10 秒内完成。为什么不能锁整个房间因为一个房间一天有 48 个时段每个时段之间互不影响锁整个房间会造成大量无意义的排队。代码结构大概是这样的String lockKey room:schedule: schedule.getId(); boolean locked redisLock.lock(lockKey, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(当前时段有顾客正在预订请刷新后重试); } try { RoomSchedule schedule scheduleMapper.selectById(scheduleId); if (schedule.getStatus() ! ScheduleStatus.FREE) { throw new BizException(该时段已被占用); } int updated scheduleMapper.compareAndSetOccupy(scheduleId, reservation.getId(), ScheduleStatus.FREE, schedule.getVersion()); if (updated 0) { throw new BizException(手速慢了该时段刚刚被订走); } // 创建预约单进入待支付状态 } finally { redisLock.unlock(lockKey); }Redis 锁只是第一道门槛不能当唯一保障。锁在业务执行过程中如果过期另一个线程可能进入同一个时段所以必须有 CAS 更新兜底用version保证最终只有一个人能把 schedule 改成 OCCUPIED。这样即使 Redis 抖动数据库也不会产生脏数据。4.2 待支付预约单的占用与释放用户发起预约后如果在 5 分钟内没有完成支付这个时段不能一直占着。我用t_reservation.status来标记PENDING_PAY同时给expire_time字段专门的定时任务每隔一分钟扫一次过期未支付单调用释放方法。释放方法是把 schedule 的status改回 FREEcurrent_reservation_id置空预约单状态改成EXPIRED。这个释放任务有几个细节。第一不能直接在任务里写死了“超过 5 分钟就扫”要按记录的expire_time判断避免因历史脏数据误释放。第二扫码释放时也要带where current_reservation_id #{reservationId}防止用户刚支付完成却被定时任务误释放。第三释放和支付可能竞争同一个 schedule所以释放前应该重新加 Redis 锁不然会破坏 current_reservation_id 的指向。4.3 支付回调幂等和状态机支付回调是整个对账环节的命门。微信或支付宝的异步通知不是一次性的可能会重复推送成功回调后的第二天凌晨还有对账单。我的处理方式是t_payment_record表用transaction_id唯一索引回调进来先按transaction_id查如果已存在且状态是 SUCCESS直接返回成功给支付渠道不再继续处理业务。同时用order_no对应业务订单更新订单状态时写update t_order set statusPAID where order_no? and statusPENDING_PAY影响行数为 0 就说明订单状态已经不是待支付直接丢掉。这里我把订单状态机固定为PENDING_PAY - PAID - IN_PROGRESS - COMPLETED反向分支是PENDING_PAY - CANCELLEDPAID - REFUNDING - REFUNDED。千万别让一个订单直接从 PENDING_PAY 跳到 COMPLETED中间任何一步漏掉都会导致用户进了门、钱却还在冻结。4.4 缓存和数据库的一致性问题时段查询接口的压力不小用户选日期后要拉出当天所有房间的时段状态。这个数据我把未来 7 天预生成好并缓存到 Rediskey 是room:{roomId}:schedule:{date}value 是一段 JSON。每次更新时不是先改缓存再改数据库而是先改数据库然后把缓存删掉让下次请求重新加载。这样能避免缓存和数据库同时在极端情况下不一致。删除缓存时不要用del一把梭最好把 key 的版本号带上。因为一个时段被更新后旧缓存可能还在被另一个请求读取如果缓存 key 是简单字符串会出现“旧缓存覆盖新数据”的问题。我的做法是在缓存 value 里带上 schedule 的 version查询时如果发现缓存里的 version 和数据库不一致就丢弃缓存再查库。这个方案对预约系统已经够用没必要上更重的 Canal 订阅 binlog。5. API 接口设计和设备对接5.1 小程序端需要的接口小程序端接口设计时可以按“用户操作路径”来梳理进入门店列表 - 房间详情 - 时段日历 - 创建预约 - 支付 - 开门 - 结束离场。核心接口大概是下面这些GET /api/v1/rooms 门店房间列表 GET /api/v1/rooms/{roomId}/schedules?date2025-06-01 POST /api/v1/reservations 创建预约单 POST /api/v1/orders/{orderNo}/pay 支付/押金冻结 POST /api/v1/orders/{orderNo}/openDoor 开门 GET /api/v1/orders/{orderNo} 查询订单详情 POST /api/v1/orders/{orderNo}/finish 提前结束每个接口都要做参数校验尤其是时间参数不能只靠前端传。服务端要把start_time解析成LocalDateTime再和系统当前时间、房间配置的可预约区间做比较防止有人手工调接口订一个已经过去的时间段。日期范围也要校验不能允许用户一次订超过 60 天否则会打爆 schedule 查询。5.2 门锁联动和开门凭证门锁联动一开始容易做成了“用户点开门服务端去调门锁”但真正上线后你会发现门锁厂商的 HTTP 接口响应很不稳定有时 3 秒才返回小程序端已经超时了。所以要把开门动作设计成异步任务后端接收请求后先校验订单状态和当前时间然后创建一个door_open_command立即返回“指令已下发”真正调门锁的动作放到线程池里执行执行结果通过 WebSocket 推送给小程序。开门凭证我用的是 UUID 生成的动态 token有效期设置为预约开始时间前后各 15 分钟。顾客在小程序生成开门二维码或直接点按钮后端校验预约状态必须是 PAID 且当前时间落在有效期内才允许调门锁。这里有个细节如果用户在预约开始前 30 分钟就点开门系统不应该直接拒绝而是提示“距离预约还有 30 分钟暂时无法开门”要留一个友好提示而不是干巴巴的报错。5.3 智能电表和能耗异常茶室空调是大功率设备无人值守时要能感知“房间是否有人、空调是不是忘关了”。我在每间房放了智能电表和人体红外传感器电表每 30 秒上报一次功率红外传感器每 5 分钟上报一次有人/无人。服务端收到数据后只做两层判断房间订单处于 IN_PROGRESS 但功率超过阈值且持续 15 分钟推一条“疑似忘关空调”的告警订单结束后 30 分钟房间功率依然很高自动尝试远程断电并告警。Java 侧接收设备上报用的是 MQTT 加一层RabbitListener处理上报数据先写到t_device_callback_log再做规则判断。不要在上报接口里同步写业务表和推送设备故障时接口会连锁超时。连硬件时还要注意设备回调往往有签名校验我踩过一个坑厂商文档说签名算法是“把参数名排序后拼接再加密”但他们用的排序是字典序我用 StringBuilder 拼了一遍没排序结果线上验签失败。后来统一用 TreeMap 排序再拼接才稳定下来。这个细节在 Java 对接第三方接口时非常常见。6. 几个高频 Bug 和排查经验6.1 定时任务重复执行把订单结算了两遍上线第二天就出问题一个订单被结算了两次顾客余额扣成负数。原因是部署了两台应用实例Scheduled每分钟的结算任务在两台机器上同时跑先到的那台把订单改成 COMPLETED后到的又以为自己也要处理。解决办法很简单第一步结算 SQL 里带上状态条件update t_order set statusCOMPLETED where order_no? and statusIN_PROGRESS影响行数为 0 就跳过。第二步用 Redis 分布式锁包住整个定时任务锁的 key 是任务名过期时间设为任务执行上限比如 60 秒。这类问题最好的排查路径是看日志里的X-B3-TraceId或者自己在 MDC 里放的traceId。两个实例的日志如果 traceId 不一样但订单号相同就要怀疑是不是多实例重复消费了而不是业务逻辑写错。6.2 接口超时的锅一半在连接池小程序端反馈“开门按钮转圈 5 秒没反应”我们查了后端调门锁的 HTTP 接口没有设置超时。Java 自带的HttpURLConnection默认超时是无限等待只要门锁网关挂住请求就一直挂在业务线程里很快把 Tomcat 线程池打满。后来统一换成了HttpClient并显式设置connectTimeout(2000)和responseTimeout(3000)同时把门锁调用改为异步执行。还有一次是数据库连接池被打满事后看是某个慢查询把HikariCP的 20 个连接全占了。排查方法是打开 HikariCP 的leakDetectionThreshold设置成 10 秒如果连接占用超过阈值就会在日志里打完整堆栈能直接定位到是哪个 Service 方法忘了释放连接。如果是 MyBatis 的SqlSession一般不会漏但自己手写 JdbcTemplate 时很容易漏掉finally里的关闭。6.3 那些跟 Java 环境相关的坑项目里有人本地装了 JDK 17测试服务器还是 JDK 8编译时就出现 “java: 警告: 源发行版 17 需要目标发行版 17” 这类问题最后统一在pom.xml的maven-compiler-plugin里指定release17/release并且每个人的JAVA_HOME都指向同一个 JDK 版本。这个在团队协作里是必须提前盯住的不然每个新同事第一周都要浪费半天在环境变量上。另外一个小坑是开发机用的是 JDK 17代码里用了StringBuilder拼接日志结果生产机器上日志编码不对中文变成乱码。这不是 Java 版本问题是启动脚本里的-Dfile.encodingUTF-8没设。建议所有 Java 服务启动命令统一追加这句日志文件多语言环境下更安全。6.4 门锁厂商回调乱序导致状态回退设备回调不是严格按照开门、关门顺序来的可能出现“关门”回调先到“开门”回调后到。每个回调都有event_time字段服务端判断时必须加一条只接受event_time不早于当前状态的最近一次事件时间否则丢弃。这个逻辑看起来简单但如果状态表里没有记录“当前状态更新时间”就得临时去翻日志非常痛苦。所以t_device表一定要加last_event_time回调时做一次比对。还有一个经验回调接口一定要先落库再通知业务不要先改业务状态再写日志。万一写日志失败整个状态就不可审计了。我先写t_device_callback_log再更新设备表最后再发消息给业务模块链路失败时可以从日志完整重放。7. 一些 Java 实现细节面试和实战都能用上7.1 深度拷贝和对象转换的取舍订单详情接口要把订单实体转成 DTO再拼上房间、预约时段、支付记录等数据。很多新人喜欢直接BeanUtils.copyProperties这个方法是浅拷贝碰到嵌套集合时容易把原对象的引用带出去后续修改 DTO 直接改了实体。我的做法是查询出来的实体先转成 DTO涉及集合的字段手动stream().map()重新创建不用过度设计。只有像“把订单日志序列化后存 JSON”这种场景才用 JSON 序列化实现深拷贝不要拿它当通用工具。7.2 接口签名校验和防重给小程序端写的接口不能裸奔我在网关层做了一道签名校验。规则是把除签名外的所有参数放进TreeMap按字典序排序用StringBuilder拼接成keyvalue字符串再加上时间戳和密钥做 HMAC-SHA256。这里用 TreeMap 而不是 HashMap就是为了保证签名结果不依赖插入顺序。这个细节在很多第三方对接场景里是必考也是我上面提到门锁签名踩坑后的标准解法。7.3 一个适合写进 Java 面试题的项目难点做完这个项目我再回头看发现它非常适合用来考察 Java 工程师的综合能力。它能聊的点太多了分布式锁的粒度怎么定、Redis 缓存和数据库一致性怎么保证、支付回调的幂等怎么做、状态机怎么设计、定时任务在集群环境下怎么避免重复执行还有深拷贝和浅拷贝的坑。很多 Java 面试题里会问 AQS、ConcurrentHashMap、StringBuilder 的线程安全性这些都可以在这个项目里找到真实的对应场景。比如有人在面试题里问“StringBuilder 是线程不安全的为什么你在项目里用了”。这里的回答是签名拼接只在私有方法里使用没有跨线程共享真正跨线程的订单结算用了ReentrantLock和ConcurrentHashMap来做并发控制。所以不是所有地方都不能用 StringBuilder关键是要说明线程边界在哪里。7.4 后续扩展的方向以及我最想保留的设计这套方案跑通后下一步我计划做两件事一是把预约时段的选择改成组件化的日历视图用户连续选多个时段时系统自动在后台合并成一个订单减少支付次数二是加一个无人茶室专用的设备巡检模块每天凌晨自动执行门锁开合测试、电表心跳检查有问题提前通知运营而不是等顾客到店了才发现门打不开。如果业务量再涨就考虑把 RoomSchedule 的存储拆成按月分表或者把时段查询的缓存换成更长时间。不过在那之前我最大的体会是别急着上微服务和各种中间件先把一个房间、一个时段的并发控制用最简单的方式做对后面的扩展才会顺手。把当前订单状态、当前占用时段、设备最近心跳这些基础数据管干净无人门店的体验就已经能超过大多数线下门店了。
返回列表