news 2026/9/26 0:07:25

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
无人茶室系统实战: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.0,InnoDB。核心表可以分成资源域、交易域、设备域三类。资源域是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 status='OCCUPIED' where status='FREE'”这种 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改回 FREE,current_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 status='PAID' where order_no=? and status='PENDING_PAY',影响行数为 0 就说明订单状态已经不是待支付,直接丢掉。

这里我把订单状态机固定为:PENDING_PAY -> PAID -> IN_PROGRESS -> COMPLETED,反向分支是PENDING_PAY -> CANCELLED,PAID -> REFUNDING -> REFUNDED。千万别让一个订单直接从 PENDING_PAY 跳到 COMPLETED,中间任何一步漏掉都会导致用户进了门、钱却还在冻结。

4.4 缓存和数据库的一致性问题

时段查询接口的压力不小,用户选日期后要拉出当天所有房间的时段状态。这个数据我把未来 7 天预生成好,并缓存到 Redis,key 是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?date=2025-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 status='COMPLETED' where order_no=? and status='IN_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里指定<release>17</release>,并且每个人的JAVA_HOME都指向同一个 JDK 版本。这个在团队协作里是必须提前盯住的,不然每个新同事第一周都要浪费半天在环境变量上。

另外一个小坑是开发机用的是 JDK 17,代码里用了StringBuilder拼接日志,结果生产机器上日志编码不对,中文变成乱码。这不是 Java 版本问题,是启动脚本里的-Dfile.encoding=UTF-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拼接成key=value字符串,再加上时间戳和密钥做 HMAC-SHA256。这里用 TreeMap 而不是 HashMap,就是为了保证签名结果不依赖插入顺序。这个细节在很多第三方对接场景里是必考,也是我上面提到门锁签名踩坑后的标准解法。

7.3 一个适合写进 Java 面试题的项目难点

做完这个项目我再回头看,发现它非常适合用来考察 Java 工程师的综合能力。它能聊的点太多了:分布式锁的粒度怎么定、Redis 缓存和数据库一致性怎么保证、支付回调的幂等怎么做、状态机怎么设计、定时任务在集群环境下怎么避免重复执行,还有深拷贝和浅拷贝的坑。很多 Java 面试题里会问 AQS、ConcurrentHashMap、StringBuilder 的线程安全性,这些都可以在这个项目里找到真实的对应场景。

比如有人在面试题里问“StringBuilder 是线程不安全的,为什么你在项目里用了”。这里的回答是:签名拼接只在私有方法里使用,没有跨线程共享;真正跨线程的订单结算用了ReentrantLock和ConcurrentHashMap来做并发控制。所以不是所有地方都不能用 StringBuilder,关键是要说明线程边界在哪里。

7.4 后续扩展的方向,以及我最想保留的设计

这套方案跑通后,下一步我计划做两件事:一是把预约时段的选择改成组件化的日历视图,用户连续选多个时段时,系统自动在后台合并成一个订单,减少支付次数;二是加一个无人茶室专用的设备巡检模块,每天凌晨自动执行门锁开合测试、电表心跳检查,有问题提前通知运营,而不是等顾客到店了才发现门打不开。

如果业务量再涨,就考虑把 RoomSchedule 的存储拆成按月分表,或者把时段查询的缓存换成更长时间。不过在那之前,我最大的体会是:别急着上微服务和各种中间件,先把一个房间、一个时段的并发控制用最简单的方式做对,后面的扩展才会顺手。把当前订单状态、当前占用时段、设备最近心跳这些基础数据管干净,无人门店的体验就已经能超过大多数线下门店了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 0:06:29

HTTP协议与www子域名的分层原理及工程避坑指南

1. 别再把“http://”和“www.”当成一回事了&#xff1a;一个被所有人忽略的底层认知断层你有没有点开过这样的链接&#xff1a;http://www.example.com&#xff0c;然后下意识觉得“哦&#xff0c;这是个网站”&#xff1b;接着又看到https://example.com&#xff0c;心想“这…

作者头像 李华
网站建设 2026/9/26 0:03:43

超低能耗建筑K值要求能否满足?浙东铝业建筑型材解析

核心摘要浙东铝业的超低能耗系统门窗产品&#xff0c;资料显示保温性能可达 K≤1.4W/(㎡K)&#xff0c;能够对应上海地区超低能耗住宅对门窗保温性能的应用需求。判断建筑是否满足超低能耗要求&#xff0c;不能只看铝型材本身&#xff0c;还需要结合玻璃、隔热条、密封系统、开…

作者头像 李华
网站建设 2026/9/25 23:59:45

学校官网模拟全流程实践:从页面布局到后端接口与部署

如果你正在找一门 Web 大作业的题目&#xff0c;或者刚开始接触 Web 前端开发想做点能拿来展示的东西&#xff0c;“学校官网模拟”几乎是最稳的选择。题目看着简单&#xff0c;但要把导航、新闻列表、轮播 Banner、二级页面、后台数据都串起来&#xff0c;其实已经把前端布局、…

作者头像 李华
网站建设 2026/9/25 23:52:27

ViewFaceCore实战:C#本地人脸识别从检测到比对全流程

简介&#xff1a;ViewFaceCore 是一份面向 C# 开发者的开源人脸识别库资源&#xff0c;基于 SeetaFace6 封装&#xff0c;适合需要在 .NET 项目中快速集成人脸检测与识别能力的开发者&#xff0c;无论是初学者还是有一定经验的工程师都能低门槛上手。资源包共 57 个文件&#x…

作者头像 李华