做场馆预约系统的同学应该都知道,真正让人头痛的从来不是“能用小程序下单”,而是把线下球馆改造成无人值守的完整闭环。这篇文章要聊的,是一套用 Java 做后台、微信小程序做前端的无人自助乒乓球预约系统。核心价值在于让球友在小程序里自助订台、扫码入场、自动计时,场地管理员完全可以不用坐在前台守着排班表,甚至晚间无人时段也能正常营业。
我当初做这类项目时踩了不少坑,尤其是“同一时段被多人同时抢订”“支付回调重复到达”“扫码进场后如何联动门禁”这类边角问题,文档里基本不会写,但恰恰是上线后最容易翻车的地方。所以这篇博客不只是标题里的“源码实现方案”,更像是一份从数据库建模、状态机设计到并发处理和排障经验的复盘记录。如果你准备自己写一套类似的球场、球房、共享空间预约系统,或者公司内部要做一套带硬件的无人值守方案,这篇能把很多弯路上的经验直接给你省掉。
特别是标题里提到的 Java、小程序、源码、开源,我理解大家真正想要的不是那种“只有 CRUD”的玩具代码,而是能面对真实并发、能接微信支付、能对接门禁设备的工程化实现。下文所有代码片段默认基于 Spring Boot 2.x + MyBatis-Plus + MySQL + Redis + 原生微信小程序这套组合,这套组合在现阶段做中场馆预约类小程序成本最低、资料最多、排错最方便。
1. 项目背景与整体设计思路拆解
1.1 “无人自助”的本质需求是什么
先说清楚业务目标。乒乓球馆无人自助,说白了就是“无人收银、无人排班、无人看守”,但用户想要的服务体验不能降级。放在真实场景里,我们需要回答这些问题:
- 用户怎么知道今天哪些时段还有空台?
- 用户选定时段后,怎么锁定订单并完成支付?
- 到球馆之后,怎么证明“我确实订了这个台”?
- 灯控、门禁、闸机这些设备应该听谁指挥?
- 有人订了位置却没来,场馆的损失怎么约束?
如果只是做一个简单的“记表格”系统,那 MySQL 一张表就够了。但真正无人自助场景下,系统必须把线上预约和线下履约串起来,中间任何一环断了,用户体验都会崩。比如用户付了钱到店发现门禁不识别订单,这比“订不到台”严重得多。
所以从架构层面就把系统拆成两块:一块负责“交易与调度”,处理预约、支付、取消、超时关单,另一块负责“履约与设备”,处理签到、核销、门禁联动、灯控指令。这两块在内核上高度耦合,但接口边界必须清晰。后面很多表设计和接口划分,都是围绕“预约状态”这个唯一事实来源展开的。
1.2 技术选型:Java + Spring Boot + 小程序为什么够用
经常有人问,这种规模的项目用 Java 是不是太重了?其实这要看团队背景。像“无人自助乒乓球预约”这种业务,核心逻辑落在订单状态机、并发防重、支付回调、定时任务上,Java 在这几块生态非常成熟,尤其分布式锁、事务控制、任务调度相关框架都现成。更现实的一点是,国内大量做这类项目的开发者本身就是 Java 后端背景,后续要在这套代码上继续加会员系统、多门店、优惠券,Java 的可维护性是明显优于脚本方案的。
- 后端:Java 8+ / Spring Boot 2.7 / MyBatis-Plus / MySQL 8.0 / Redis
- 管理端:Spring Boot 内置 Thymeleaf 或者独立 Vue 项目都可以,核心是提供接口
- 小程序端:微信原生小程序框架,不引入重型 UI 库
- 部署:单台云服务器起步,Nginx 反向代理,HTTPS 证书必须(小程序后台强制要求)
为什么不用更复杂的微服务?第一阶段根本不需要。预约系统的瓶颈不在“服务数量多”,而在“单点并发正确性”。把 MySQL 和 Redis 用好,单机 Spring Boot 撑一个小型城市连锁球馆完全没有压力。引入太多中间件只会让部署和排查变难,真实项目中“够用就好”才是务实的选择。
1.3 功能模块地图
一套完整系统至少要有以下几个模块:
- 场地管理:乒乓球台编号、所在门店、可预约时间段配置、暂停开放标记
- 预约订单:用户选台选时段、生成订单、微信支付、预约记录查询
- 签到核销:用户到店出示小程序码,后端核销并触发设备指令
- 信用体系:爽约记录、取消次数限制、信用分扣减
- 管理后台:订单管理、场地状态管理、每日营收与到场统计
- 系统配置:营业时间、可提前预约天数、每个用户每日最大预约数
这里需要特别说明,乒乓球馆的预约粒度通常是一张台 + 一个半小时/一小时的时间片。不同场馆规则不同,有的高峰时段只让订一小时,闲时可以连续订两小时,这些最好做成场地配置项而不是写死在代码里。我在第二节建表时会把这种扩展性考虑进去。
2. 核心表结构与状态机设计
2.1 订单怎么建模:从租场到履约
数据库建模是预约系统最不能省的一步。很多新手上来就建一张“预约表”,字段塞得乱七八糟,后面加需求全部靠硬编码。我建议按领域模型拆成场地表、时段表、订单主表、订单状态流水表这四张核心表。
场地表最容易想到,但很多人会漏掉“场地可预约时段模板”。比如场馆规定周一至周五 10:00-22:00 营业,每 30 分钟一个可约时间片;周末延长到 23:00。如果不建时段模板表,散落在 Java 代码里的业务判断会很痛苦。所以至少需要这样两张基础表:
CREATE TABLE `t_court` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '场地ID', `court_name` VARCHAR(50) NOT NULL COMMENT '场地名称,如A区1号台', `store_id` BIGINT NOT NULL COMMENT '所属门店ID', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0停用', `open_time` VARCHAR(10) NOT NULL DEFAULT '09:00' COMMENT '开始营业时间', `close_time` VARCHAR(10) NOT NULL DEFAULT '22:00' COMMENT '结束营业时间', `slot_minutes` INT NOT NULL DEFAULT 60 COMMENT '预约时间粒度(分钟)', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='乒乓球场地表'; CREATE TABLE `t_book_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '预约单ID', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `court_id` BIGINT NOT NULL COMMENT '场地ID', `book_date` DATE NOT NULL COMMENT '预约日期', `time_slot` VARCHAR(16) NOT NULL COMMENT '预约时段,如18:00-19:00', `amount` INT NOT NULL DEFAULT 0 COMMENT '支付金额(分)', `pay_status` TINYINT NOT NULL DEFAULT 0 COMMENT '支付状态 0待支付 1已支付 2已退款', `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态见状态机', `sign_status` TINYINT NOT NULL DEFAULT 0 COMMENT '签到状态 0未签到 1已签到', `cancel_reason` VARCHAR(255) DEFAULT NULL COMMENT '取消原因', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_court_slot` (`court_id`, `book_date`, `time_slot`), KEY `idx_user_date` (`user_id`, `book_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='乒乓球预约订单表';这里有两个细节很容易被忽略。
第一,uk_court_slot唯一索引极其重要。它把“同一张台同一个日期同一个时段只能有一个预约单”从业务逻辑层面降维成数据库强约束。就算代码里出现并发漏洞,只要索引存在,数据库也会拒绝第二条插入。后面讲防重时会再次提到它。
第二,订单表里同时存在pay_status和order_status。这两个字段不能混为一谈。pay_status描述资金层面状态,order_status描述业务履约层面状态。比如用户预约后还没支付,但订单 15 分钟超时被自动取消,这是业务状态变化,不是支付状态变化。分别维护可以避免以后统计营收时被杂七杂八的业务状态干扰。
2.2 预约状态机的流转规则
状态机是这类系统的灵魂。我见过很多项目把订单状态随便写个 int,代码里到处都是if (status == 1 || status == 2),后面加状态时改到想哭。正确做法是先把所有状态流转画清楚,用枚举或者常量类统一收口。
我们可以定义这几个业务状态:
0 待支付:用户提交预约成功,但钱还没付1 已支付/待使用:预约生效,等待用户到店2 已签到:用户扫码入场,设备已联动3 已完成:预约时段结束,订单自动归档4 已取消:用户主动取消或者超时系统取消5 爽约:用户未取消但也没在有效时间内签到
状态流转规则是这样的:
- 待支付 → 已取消:用户取消或超时未支付
- 待支付 → 已支付(待使用):支付成功回调
- 待支付 → 已支付:部分场馆支持线下转账,由管理员后台标记
- 已支付(待使用) → 已签到:用户到店扫码核销
- 已支付(待使用) → 已取消:开场前 2 小时以上用户可以申请退款取消
- 已签到 → 已完成:时间到订单自动完成
- 待支付/已支付 → 爽约:过了最晚签到时间仍未签到
把整个状态流转写成一个 Java 枚举,每次更新状态之前先校验当前状态是否合法,能大大减少脏数据:
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付/待使用"), SIGNED(2, "已签到"), FINISHED(3, "已完成"), CANCELLED(4, "已取消"), NO_SHOW(5, "爽约"); private final int code; private final String desc; private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(0, Set.of(1, 4)); ALLOWED_TRANSITIONS.put(1, Set.of(2, 4, 5)); ALLOWED_TRANSITIONS.put(2, Set.of(3, 5)); } public static void checkTransition(int from, int to) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(from); if (allowed == null || !allowed.contains(to)) { throw new BizException("非法状态流转: " + from + " -> " + to); } } }状态流水表记录每次改变的操作人、操作时间、原状态、新状态和触发原因。这张表平时用不上,但凡是涉及到“用户说我没取消怎么订单没了”“管理员误操作了状态”这类客诉,流水表就是唯一的破案依据,一定不要省。
2.3 信用分与爽约规则的配套设计
无人自助场景下,“爽约”是场馆最直接的损失。一个人订了台不到,这个时段空着无法卖给别人,造成的是实打实的机会成本。所以系统里光有预约状态还不够,还要在用户维度加信用约束。
我采用的方案比较轻量:用户表里维护credit_score,默认 100 分,每爽约一次扣 20 分,取消不扣分但有次数限制(比如每月超过 3 次取消,后面 7 天不能订热门时段)。信用分低于 60 的用户,预约时必须先支付全款且不可取消。信用分记录的流水表和订单流水一样,作为后续人工申诉的依据。
这里要注意爽约的判定时间。乒乓球一场一般 1 小时,如果把“最晚签到时间”定在开场后 10 分钟,用户迟到 20 分钟才到店,扫码时订单已经变成爽约状态了,处理起来会有争议。实际项目里我更建议把规则调整为“超过预约开始时间 20 分钟未签到,先发送服务通知提醒用户;超过 30 分钟仍未签到,才标记爽约”。运营层面做了一定缓冲,代码里只差一个常量配置,但用户体验差别很大。
3. 预约接口实现与并发防重
3.1 一条预约链路从点击到数据库发生了什么
用户在乒乓球预约小程序里选好场次、点提交,前端会调用后端预约接口,整个链路看起来简单,但每一层都有各自要解决的问题。我按时间顺序把这条链路完整拆出来:
- 小程序端先调用
wx.login拿到临时 code,后端拿 code 换 openid,生成自己的登录态 token - 用户进入预约页,小程序请求“某天某球台剩余可预约时段”接口
- 用户选中时段点提交,前端带上 courtId、bookDate、timeSlot 调预约接口
- 后端校验用户是否登录、信用分是否足够、场地是否开放、当前时段是否已被订
- 校验通过后,在 Redis 里加锁,防止同一时段多人重复提交
- 再查一次数据库确认该时段确实空闲,然后插入预约订单
- 如果用户选择微信支付,后端生成微信支付预支付单,返回小程序端拉起收银台
- 用户支付完成后,微信支付服务器异步通知后端,后端更新订单状态
大多数第一次接触预约系统的开发者,会把第 4 和第 6 步混在一起,觉得“我查一遍数据库没记录就可以插入”。但在并发较高时,两个请求可能同时查到“无记录”,然后同时插入成功,最后依赖唯一索引报错提示用户。所以第 5 步 Redis 分布式锁和第 6 步数据库唯一索引缺一不可。
3.2 同一时段被多人抢订:三种常见方案的取舍
- 方案一:纯数据库乐观锁,在
t_book_record上加唯一索引,插入时捕获DuplicateKeyException。这个方案最稳,但用户体验一般,因为用户看到的是“当前时段已被订”的报错页面,我们很难在报错前给出“推荐其他时段”的引导。 - 方案二:Redis 分布式锁,在业务层先锁住 courtId + bookDate + timeSlot 这个 key,拿到锁的请求继续执行,其他请求快速失败返回“请重新选择时段”。这个方案胜在灵活,既能防重又能做友好的竞争提示。
- 方案三:Redis 原子扣减库存。把每个时段的剩余名额作为 Redis 计数器,预约前先 DECR,成功再写 MySQL。这个方案适合按名额售卖的场次,但对乒乓球台这种“一对一锁定”场景来说有点重,还多了 Redis 和 MySQL 数据一致性维护成本。
我实际落地时用方案二 + 方案一兜底。锁保证同时只有一个请求能执行“查库-插入”这段逻辑,唯一索引保证即使锁因为异常没用上,数据库也不会产生双订单。
3.3 预约接口核心代码片段与解释
下面这段是预约接口的 Service 层核心代码,为了让大家看清主链路我把参数校验、日志等都做了精简:
@Service @Slf4j public class BookOrderService { @Resource private StringRedisTemplate stringRedisTemplate; @Resource private CourtMapper courtMapper; @Resource private BookRecordMapper bookRecordMapper; private static final String BOOK_LOCK_PREFIX = "book:lock:"; @Transactional(rollbackFor = Exception.class) public BookResult createOrder(BookCreateRequest request, Long userId) { String date = request.getBookDate().toString(); String slot = request.getTimeSlot(); String lockKey = BOOK_LOCK_PREFIX + request.getCourtId() + ":" + date + ":" + slot; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("手慢了,这个时段刚刚被别人订走"); } try { Court court = courtMapper.selectById(request.getCourtId()); if (court == null || court.getStatus() != 1) { throw new BizException("场地不存在或已停用"); } // 校验营业时间与时段格式 checkCourtOpenTime(court, request); // 查当前时段是否已存在有效订单 int existsCount = bookRecordMapper.countValidOrder( request.getCourtId(), request.getBookDate(), slot); if (existsCount > 0) { throw new BizException("该时段已被预约"); } String orderNo = OrderNoGenerator.generate(); BookRecord record = new BookRecord(); record.setOrderNo(orderNo); record.setUserId(userId); record.setCourtId(request.getCourtId()); record.setBookDate(request.getBookDate()); record.setTimeSlot(slot); record.setAmount(request.getAmount()); record.setPayStatus(0); record.setOrderStatus(0); record.setSignStatus(0); try { bookRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.warn("并发插入唯一索引冲突, orderNo={}, courtId={}, slot={}", orderNo, request.getCourtId(), slot); throw new BizException("该时段刚刚被其他人抢订,请选择其他时间"); } // 生产环境这里应该发MQ或异步任务通知用户"预约已创建,等待支付" return BookResult.of(record); } finally { stringRedisTemplate.delete(lockKey); } } }这里有三个点需要重点解释。
第一,setIfAbsent第二个参数“1”,第三个参数是锁过期时间。锁过期时间一定不能设太短,否则业务还没执行完锁就自动释放了,后续请求又会进来。但设太长也不行,万一服务宕机,这个 key 要等很久才能被其他请求抢到。我一般设成 10 秒,因为预约接口内部“查场地 + 查订单 + 插入订单”根本用不了 10 秒。如果你后面在事务里接了外部系统调用,比如发短信、调支付接口,那锁的过期时间可能要动态调长,具体场景具体分析。
第二,@Transactional和 Redis 锁释放顺序非常容易踩坑。当前代码里方法结束后事务先提交,然后 finally 释放锁,这个顺序是对的。但如果有人在方法内部手动删锁,而事务还没提交,另一个线程拿到锁后查不到新插入的数据,就会出现“明明数据库已经有订单了,下一个用户还能再预约一次”的诡异现象。所以数据库唯一索引是必须有的最终防线。
第三,countValidOrder查询的 SQL 不能只查 order_status = 0 或 1。如果用户下单后支付超时订单变成已取消,这个时段理论上应该释放给其他人。但实际中要防止“用户待支付订单反复占住一个时段”影响其他用户抢订,所以查询时要排除无效状态。标准 SQL 类似这样:
SELECT COUNT(1) FROM t_book_record WHERE court_id = #{courtId} AND book_date = #{bookDate} AND time_slot = #{timeSlot} AND order_status IN (0, 1, 2)只统计待支付、已支付、已签到这三种“有效占用”状态。已取消、已完成、爽约都不会继续占住时段(已完成是时段已经结束了,不会有人再来抢)。这个细节不处理好的话,用户反复提交订单又取消,该时段会被一直标记为不可约,直到有人工干预。
3.4 支付回调幂等与超时关单
用户在小程序里拉起微信支付后,资金的最终确认不是靠前端返回结果,而是靠微信服务器异步通知后端。支付回调接口和预约接口一样,也必须有幂等处理。微信支付通知有一个显著特点:同一个支付结果可能推送多次,而且顺序不能保证。如果回调接口不做幂等,就可能出现同一笔订单被重复更新、重复向用户推送通知。
我处理回调的方法是:先按 order_no 查出订单,如果订单已经是已支付状态,直接返回成功响应给微信服务器,不再重复操作资金和状态。如果订单当前是待支付,才执行支付确认,并把订单状态从待支付流转到已支付,同时给用户发送入场提醒。这个“查旧状态 + 比对 + 更新”的过程,本质上是用乐观更新方式保证幂等:
UPDATE t_book_record SET pay_status = 1, order_status = 1 WHERE order_no = #{orderNo} AND order_status = 0只有当一个订单还处于待支付时,这个 UPDATE 语句才会成功更新一条记录。如果影响行数是 0,说明之前已经处理过回调,直接跳过即可。这样连“先查再改”都不用,一条 SQL 就能把幂等做掉,性能和服务稳定性都更好。
超时关单的常见做法是“用户下单选了时段但 15 分钟没支付,系统自动取消释放”。我实现时没有用复杂的延迟队列,直接在订单表建了支付截止时间,再加一个每 5 分钟跑一次的定时任务去扫所有超时未支付订单。对大多数中小球馆来说,准实时就够用了。如果你希望体验更优雅,可以在用户进入支付页后返回“支付二维码已过期”的提示,让用户重新拉起支付,而不是直接取消订单。两条路径都需要考虑。
4. 小程序端与后台联动
4.1 登录、签名与请求封装
小程序端的技术难点不在页面 UI,而在登录态和接口安全。微信小程序的登录流程是wx.login拿到 code,然后调后端接口换 openid 和 token。后端不能把 openid 直接返回给前端明文存储,原因很简单:如果别人的小程序拿到你的 openid,很容易伪装成你调用业务接口。标准做法是 openid 存后端,前端只持有后端签发的 token,后续每次请求在 Header 里带 token。
另外标题里提到“签名”这个关键词,确实是这类无人自助系统不能漏的一个细节。小程序端每次请求都带上:时间戳、随机串、签名。签名算法一般是把业务参数 + 时间戳 + 随机串 + 密钥排序拼接后做 MD5 或 HMAC,后端用同样的密钥重新算一遍,不一致就拒绝请求。这个机制主要是防止有人抓包后拿你的接口地址乱刷,比如绕过前端页面直接调用预约接口,把某一天的黄金时段全部占掉。
后端做一个 HandlerInterceptor 来处理签名和登录态校验非常合适:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("X-Token"); String timestamp = request.getHeader("X-Timestamp"); String nonce = request.getHeader("X-Nonce"); String sign = request.getHeader("X-Sign"); if (StringUtils.isBlank(token) || StringUtils.isBlank(timestamp) || StringUtils.isBlank(nonce) || StringUtils.isBlank(sign)) { throw new BizException("请求缺少必要认证信息"); } // 签名校验逻辑 String serverSign = SignUtil.generateSign(timestamp, nonce, getSecret()); if (!serverSign.equals(sign)) { throw new BizException("签名校验失败"); } // 重复 nonce 校验,防止重放攻击;将 nonce 存入 Redis,过期时间 5 分钟 Boolean first = stringRedisTemplate.opsForValue() .setIfAbsent("nonce:" + nonce, "1", Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { throw new BizException("重复请求"); } // 解析 token,把 userId 塞到 ThreadLocal 或 request attribute Long userId = TokenUtil.getUserId(token); request.setAttribute("userId", userId); return true; } }小程序端请求封装,推荐统一封装一层request方法,这样自动附加签名 Header,业务页面里只要负责传参数和处理结果就行。
4.2 空闲时段展示与预约表单
小程序首页通常有几个需要展示的页面块:球馆列表、日期选择、某个球台的时段面板。时段面板的数据来源不能是查所有已经预约的订单再在前端做“差集”,那样既慢又容易出错。后端直接提供一个“当天某场地的可约时段”接口,按场地营业时间拆出候选时段,再查掉这些时段里哪些已经有效占用,剩下来的就是可约时段。
后端拆时间段的核心逻辑其实是时间运算,没有太多魔法。例如一个乒乓球台从 09:00 到 22:00,每小时一个预约片,那么当天会生成 13 个候选时段。查询已占用的时段后,标记这些时段不可约即可。预约页面用 grid 展示这些时段,每个格子里的颜色代表“可约 / 约满 / 已停用”。
小程序预约表单要注意一个细节:用户在小程序端选中的 courtId、bookDate、timeSlot 这三个字段,前端看起来是数据,实际上很容易被恶意用户伪造。比如有人把 timeSlot 改成已经被订的时段,后端如果只按前端参数做校验,就会插入同一时段第二条记录。因此预约表单提交后,后端必须重新读取场地配置表做完整性校验,而不是无条件信任前端传值。签名虽然增加了一层门槛,但不能替代业务校验。
4.3 入场二维码生成与签到核销
无人自助入场最常用的方式是用户到店后出示小程序里的动态二维码或小程序码。用微信自带能力生成小程序码时,推荐用wxacode.getUnlimited接口,把订单号编码进 scene 参数,例如scene=orderNo,这样用户扫码时微信会打开小程序的签到页,页面再从 scene 参数里解析出订单号,调后端核销接口。
后端核销接口动作比较集中:校验扫码用户是否是订单本人、校验当前时间是否在有效使用窗口内、校验订单状态是已支付,然后把订单状态改成已签到,并向设备联动模块发送开灯/开门指令。核销完成后,前端页面将展示入场成功和剩余时长,同时开始显示离场倒计时,方便用户自己掌握时间。
这里有一个运营层面的提醒:入场二维码一定不能只显示用户的 openid 或者用户 ID,因为这样泄露出去后任何人都能伪造成另一个用户入场。订单对应的二维码必须是“一次性”的,一旦签到成功,同一订单再次生成或再次扫码就应该提示无效。很多第一次做无人值守的人会忽略这一点,导致一个订单二维码能被多个人反复使用,最后结算时对不上账。
5. 无人值守关联功能落地
5.1 设备联动:门禁和灯光控制的抽象
一套预约系统能不能叫“无人自助”,关键看它跟硬件设备的联动做得到不到位。但硬件领域最大的现实是品牌型号五花八门,有的门禁支持 HTTP 接口,有的只能串口控制,有的需要经过厂家的云平台中转。如果直接在业务代码里写死某一家的 SDK,以后换设备整个项目要跟着返工。
所以我建议在业务代码和具体设备实现之间加一层抽象,把“开门”“开灯”“查状态”这类动作定义成接口,让不同设备通过适配器接入。业务层只关心“用户签到成功,需要触发开门和开灯”这个动作,至于底层是走 HTTP、MQTT 还是串口,是适配器的事情。实现下来效果还算理想,靠这种方式接入了两套不同的灯光控制器。
public interface DeviceController { /** * 打开指定场地的门禁/闸机 */ boolean openDoor(String storeId, String courtId); /** * 打开指定场地灯光 */ boolean switchLight(String storeId, String courtId, boolean on); /** * 查询设备在线状态 */ DeviceStatus getStatus(String storeId, String courtId); }签到核销成功后,业务服务通过 DeviceController 发送指令。同时需要考虑设备调用失败的情况,比如门禁控制器离线、网络超时。我在项目里加了简单的失败重试机制:把开设备指令写入t_device_task表,状态为待执行,后台定时任务每 10 秒扫描一次待执行任务,调用成功才标记完成。这样就算设备网关当时不稳定,用户也能在几分钟后正常入场。
5.2 管理后台:订单管理与取消
管理后台没必要做得很炫酷,但对日常运营来说不可或缺。管理员需要看每个时段的预约情况,支持手动取消大额异常订单,更重要的是处理“用户已支付但设备没响应”的线下补偿场景。
后台订单列表页建议默认展示“当天所有有效订单”,按时间排序,每行能看到场地名、用户昵称或手机号、预约时段、金额、订单状态、签到时间、爽约时间。管理员取消订单的接口必须走和前端一致的取消逻辑,不能直接 UPDATE status 改状态,否则状态流水和退款记录都会断裂。
另外管理后台要能统计每日经营数据,比如场次使用率、到场率、退款金额。这些统计可以直接 GROUP BY 日期 + 场地,对百万级以内的订单量用普通 SQL 就足够了,不需要上大数据组件。我见过有人一上来就搞 Elasticsearch,完全没必要,数据量没到这个规模之前,MySQL 的聚合查询绰绰有余。
5.3 运营统计:预约率与爽约率看板
因为无人自助意味着没有服务人员现场盯场,所以运营数据比传统球馆更依赖系统来反馈。我觉得至少要统计两个核心指标:预约率(已预约时段 / 可预约时段)和爽约率(爽约订单 / 已支付订单)。预约率低说明场地被闲置,爽约率高说明用户预约的严肃性不足,可能需要加强信用约束或取消限制。
后台看板可以用一个简单的「每日汇总表」来实现,定时任务每天凌晨统计前一天的各项指标并写入汇总表,看板接口只查汇总表,避免每次都实时 GROUP BY 大量订单数据。页面端展示近 30 天折线图,月底自动生成月度报表,这对球馆老板判断要不要调整营业时间、增加球台数量,都有比较大的参考价值。
6. 高频问题与排错经验实录
6.1 并发抢订时 MySQL 查重失效?唯一索引兜底
这是后台最容易复现的问题。用户并发点两个预约请求,两个事务同时执行countValidOrder,都查到 0 条记录,然后两个事务同时继续插入。如果没有唯一索引,最终数据库里会出现两条同场地同时段的预约单。如果只是靠 Java 代码查重,并发稍微一大就会破功。所以uk_court_slot唯一索引不是可选项,而是必须项。插入时捕获DuplicateKeyException,业务层就能把并发冲突转成友好的“该时段已被抢订”提示。
网上很多帖子和课程会教你在代码里“先查询再插入”,但很少告诉你:查询和插入之间必须要有一个并发控制手段,要么数据库锁,要么唯一索引,要么分布式锁。真实项目中,我建议“唯一索引兜底 + 分布式锁提前拦截”,用户看到的体验会更好,数据库也不会因为并发插入大量靠异常来兜底。
6.2 Redis 锁提前释放导致订单重复插入
实际开发里我踩过一回:事务里有一段调用外部短信服务的逻辑,导致整个事务方法执行时间超过了我预估的锁过期时间,Redis 锁先自动释放了,后面进来的请求拿到锁发现数据库还没有记录,就执行二次插入。第一批订单插入后,短信服务响应慢,事务一直没提交,第二批带着相同 courtId 和 timeSlot 的数据就进来了。第一批事务提交时,数据库层做了状态流转,第一批本身没问题,但第二批是在锁过期后进来的,它查库时因为 MVCC 还没看到第一批的未提交数据,判断“该时段空闲”,于是插入,直接被唯一索引拦截下来。
踩过这个坑后,我不再手动把锁过期时间设成“感觉够用就行”,而是把锁内要执行的逻辑保持精简,把所有外部调用都移到事务外面。如果事务方法确实无法缩短到秒级,可以考虑使用 Redisson 的看门狗机制自动续期,或者直接靠数据库唯一索引拒绝重复数据。对于多数预约场景,事务内方法保持在几百毫秒内完成,所以这种坑通常不会出现。
6.3 小程序签名校验踩坑记录
签名校验最常见的坑是签名串拼接顺序不一致。小程序端按“时间戳 + 随机串 + 业务参数”拼接,后端却按“随机串 + 时间戳 + 业务参数”拼接,两边算出来的 MD5 自然永远对不上。这个问题排查成本很低,但很容易因为前端是 H5 页面或者不同端各自写了工具函数而反复触发。正确做法是后端提供一个统一的签名计算工具,小程序端严格按照工具的说明实现,同时用一份在线调试签名的接口文档做好约束。
另一个容易被忽略的坑是签名的 HMAC 密钥放前端 JS 代码里,等于没防。真正上线时密钥最好不要直接硬编码在小程序包里,而是通过服务端接口下发一个短期有效的动态密钥,同时配合微信登录态一起使用。当然这样会增加复杂度,如果你做的是内部场馆小规模系统,固定密钥风险也可控,但要自己评估清楚。
6.4 定时任务和数据库时区问题
处理超时关单、自动完成、爽约标记时,后台一般会写几个定时任务。最容易碰到的问题是开发机器和服务器时区不一致,导致订单日期偏移。比如你数据库连接参数里没写serverTimezone=Asia/Shanghai,MySQL 默认会话时区可能是 UTC 或系统时区,Java 写入的 LocalDateTime 和数据库显示的时间自然会差 8 小时。更隐蔽的是用户约了明天的 18:00,定时任务判断“当前时间 > 预约开始时间”时会误判成已超时。
我的建议是两件事做在前面:第一,MySQL JDBC URL 显式加serverTimezone=Asia/Shanghai,同时 Linux 服务器时间用 timedatectl 设置成 Asia/Shanghai;第二,所有时间字段在 Java 中用LocalDateTime或OffsetDateTime,不要用Date混用。这两个习惯养成后,时间类 bug 能少掉一大半。
6.5 小程序码 scene 参数超长
用wxacode.getUnlimited生成入场小程序码时,scene参数有一个 32 个可见字符的长度限制。如果直接传订单号可能超长。解决办法是给订单号创建对应的短码,二维码 scene 里只带短码,短码可以存到 Redis,过期时间和订单有效周期一致,也可以直接存订单表加一个short_code字段。用户扫码后小程序端解析 scene 里的短码,再用短码查真实订单号。这个坑不复杂但很容易在上线前踩到,我见过不少团队临时把二维码换成普通二维码才绕过去。
还有一种方案是直接用一张后端生成的普通二维码图片,把订单号写进 URL 参数。但微信普通二维码在微信内识别时,体验和跳转小程序码不完全一样,对“用户扫码后打开小程序签到页”的流程不太友好。所以如果目标是把链路全部放在小程序内,还是尽早把短码机制设计好。
最后再分享一点实际感悟
乒乓球馆无人自助这类项目,表面上是在写预约系统,实际做到后面会发现它是在做“履约闭环”。用户在小程序里完成的每一步,都会映射到线下的一个资源变化。无论你接的是门禁、灯光还是发球机,设计原则都是一样的:让线上订单状态成为唯一事实来源,让每一次签到、支付、取消都有据可查。只有这样,无人场馆才能真正减少人工介入,而不是把原来前台的工作量转移到线上客服去处理。
如果你准备基于这套方案落地,我的建议是先选一家场地做小范围试运行,把预约、支付、签到、门禁全部接通后,再逐步把信用分、报表、多门店等锦上添花的功能加上来。代码结构上按“场地-时段-订单-状态流水-设备任务”这条主线去建表,后面扩展场景基本不用推翻重来。等到系统上线跑了两周,你会越发理解为什么数据库表、状态机和并发控制这几块值得在最开始就认真设计。