JAVA无人自助乒乓球预约小程序这套源码,是我在球馆智能化改造项目里从零开始落地的一套方案,后端用Java生态,小程序端用微信官方原生框架,核心功能包括微信登录、线上预约、场次计时、自助开门/开灯、微信支付和自动退场结算。项目最终也把可复用的代码片段整理成了开源部分,方便有类似需求的人直接拿去改。很多球馆/单位活动室的问题不是没人打乒乓球,而是没人值班、没法收费、约球全靠群里接龙,导致场地空闲和冲突并存,这套小程序正好解决这个矛盾。
我先说下适合谁来读这篇:如果你是Java后端,想了解一个真实预约系统的表结构、分布式锁、支付回调、设备联动;或者你是校园社团、体育馆运营方,想找一个能私有部署的开源原型;又或者你只负责小程序前端,想知道后端接口长什么样——都可以按自己的进度看。至于“无人自助”这四个字,核心不是一句口号,而是要把订单状态机和物联网控制串成一条线,让顾客到场后自己执行“开灯—开打—结算—走人”的全流程。本文会完整讲方案,贴出可直接用的开源代码片段,并把我在支付、设备控制、并发锁场上踩过的坑挨个点名,尽量做到程序员抄完也能跑。
提示:文章按照从整体流程→技术选型→表设计→后端代码→前端联调→部署运维的顺序写,公众号、技术社区等平台可直接发布使用。
1. 无人自助乒乓球馆的整体业务闭环
1.1 全自助流程到底长什么样
站在使用者的角度,无人自助预约流程可以拆成 7 个步骤:打开微信小程序查看场地空闲时段→选择日期、时段并提交预约→微信支付订场费或押金→到了球馆用小程序扫码或点击“入场”→控制器打开照明和门锁,订单进入“进行中”→打球结束点击“离场结算”→系统按实际使用时间扣费,余款原路退回。
这 7 步里最容易做砸的是第 4 步到第 7 步。很多人做预约系统只做到收钱,后面的物理开关灯却完全靠人去按,球馆晚上照样需要人值班。这不是“无人自助”。想让项目名成立,必须把订单状态和智能硬件绑定:只有支付成功的订单才能拿到控制凭证;只有同一个订单在有效时间内才能点火/开锁;离场后要把灯在几分钟内自动关掉,杜绝“人走灯亮”。
常见的低成本控制方案有两种。一是采购支持局域网HTTP接口的智能继电器,让后端直接请求设备地址控制通断电;二是用涂鸦、小米等云平台生态的智能插座,通过云云API控制开关。前者更适合私有化部署,后者更适合个人球馆快速试点。考虑到大部分Java程序员手头没有嵌入式环境,我会在后文把设备控制层抽象成“可替换接口”,先不绑定具体硬件型号。
1.2 用订单状态机代替散落的业务标记
在设计这个项目的初期,我犯过一个典型错误:场次、预约记录、支付单、设备控制记录各建各的表,每个业务各自判断“能不能取消”“能不能进场”。结果代码里到处是散落的if判断,改一个需求要动很多处。后来我把核心抽象成一张订单表,用状态机推进流程。
状态机大致如下:
| 状态 | 说明 | 可触发操作 |
|---|---|---|
| CREATED | 已创建未支付 | 取消、支付 |
| PAID | 已支付、待入场 | 入场、超时退款 |
| PLAYING | 已入场,进行中 | 主动离场结算 |
| SETTLING | 结算中 | 支付回调/退押金处理 |
| DONE | 已完成 | 查看账单、开票 |
| CANCELLED | 已取消 | 无 |
| REFUNDED | 已退款 | 无 |
一开始没有把“已取消”和“已退款”拆开,导致查询统计时无法区分到底是用户取消后退款,还是由于场地故障退款。拆开后,运营端统计有效订单、退款原因都要清晰很多。
把状态机落实到代码里时,我用了一个最简单的做法:在订单实体上提供一个 changeStateTo(OrderStatus target, OrderEvent event) 方法,内部检查当前状态能否迁移到目标状态。不能迁移就抛业务异常。这套方法避免了后面各种请求返回 200 但订单状态没改对的玄学问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:为什么用JAVA生态,又为什么保留小程序原生
2.1 Spring Boot做预约系统底座的优势
作为服务端,我最后选择了 Java 17 + Spring Boot 3.x + MyBatis-Plus 3.5.x + Redis + MySQL 8.0。这样的组合也许不算最时髦,但在预约系统这种并发量有限、业务逻辑却琐碎的场景下,Java 生态的稳定性、排查问题能力和人才储备几乎碾压其他选择。
具体省心的地方有几点:MyBatis-Plus 帮我省掉了大量单表 CRUD;Redis 负责处理分布式锁、验证码缓存、控制凭证缓存;Spring Boot 自带的声明式事务在支付回调、订单状态流转时非常重要,一旦过程中出现异常,整个流程可以精确回滚到一致状态。
有人问过为什么不用 Sa-Token 或者直接上 Spring Security 做鉴权。我的答复是:小程序端并不需要传统 Web 里那套复杂的 Session/表单登录,服务端只需要把微信登录后的用户身份映射为业务用户,再签发自己的 token 即可。Spring Security 在这里属于杀鸡用牛刀,反而让新手看不懂。真正建议引入的是 Sa-Token 的注解鉴权,权重低、上手快,可以省掉不少拦截器代码。
注意:如果团队里有同事对 Spring Boot 不熟,请把版本锁死,不要轻易升到 Boot 3.2 之后又换掉底层框架,否则排查适配问题就会耗掉大量时间。
2.2 小程序端:原生WXML还是uni-app
我曾在这个项目里长时间纠结前端方案。如果是纯微信生态,我更推荐原生小程序开发,原因很直接:原生框架的编译链最短,不会在真机预览时出现“模拟器正常但手机白屏”;使用蓝牙、扫码、微信支付等原生能力时不必做太多兼容桥接;原生分包和云开发也能让小型团队快速启动。
但如果你预期以后要同时上支付宝小程序、抖音小程序,那建议用 uni-app。它的开发体验接近 Vue 单文件组件,多端复用确实香,但代价是每次微信官方更新底层能力时,需要等 HBuilderX 适配。对我来说,这个项目只服务微信端,没必要为多端封装引入额外抽象。
小程序端的核心页面大致是:首页(公告+推荐场地)、场地列表(按小时排期)、预约确认页、我的订单页、扫码入场页、结算结果页。这些页面如果全部手写,工作量并不大,重点是每个请求都要带签名和登录态,否则后端接口等于裸奔。
2.3 设备控制层:别一上来就碰单片机
很多程序员做硬件方案时容易想复杂,甚至想自己用 ESP32 画板子、写 C 语言固件。这种探索精神很好,但对一个预约小程序项目来说,最稳妥的反而是先把软件层做好,硬件采购现成的联网继电器或智能插座。
我的接口抽象是:
java复制public interface DeviceController {
// 返回是否执行成功,如果设备不在线则抛 DeviceOfflineException
boolean turnOnPower(String deviceCode);
boolean turnOffPower(String deviceCode);
boolean checkStatus(String deviceCode);
}
在这个接口之下,我给了两个实现:HttpSwitchController 调局域网继电器协议的 /relay/on?code=xxx;CloudApiSwitchController 调云端 API 统一接入层。在本地测试环境里会用 MockDeviceController,每次开灯自动打印日志,方便前端联调。
有人担心局域网设备后端无法直连。解决方法也很简单,把设备接入到和业务服务器同一个内网里,或者做常规的公网端口映射,将设备端口放通给服务器访问。还有更省事的:设备端通过 MQTT 主动连到云服务器,后端只需向 MQTT Topic 发指令,设备收到后执行并回报结果。硬件不熟就用 MQTT 做解耦,能少踩很多网络连通性的雷。
3. 数据库与接口设计:预约系统最核心的是“锁场”而不是“下单”
3.1 数据表设计概览
我把预约系统核心数据拆成了几张主表,不含运营后台的 CMS 表,最小可用版本建议包含:会员/用户表、场地表、场地时段表(按小时生成可约时段)、订单表、支付流水表、设备控制流水表、配置表。
用户和会员可以是同一张表,字段主要记住微信 openId、unionId、昵称、手机号、余额、信用分等。场地表比较简单,唯一要注意的是表里的 device_code 字段,每个场地要绑定一组控制设备的编码,比如灯控设备 ID、门锁设备 ID。
订单表是整个系统的核心,字段建议至少包含:订单号、小程序用户 ID、场地 ID、预约日期、开始时间、结束时间、状态、应付金额、实付金额、结算金额、优惠金额、控制码/一次性 token、超时时间、创建时间等。控制码/一次性 token 非常关键,它是连接“线上订单”和“线下开灯”的桥梁。
3.2 核心的表结构SQL片段
为了大家能直接跑起来,我把核心字段整理成 DDL,代码片段里删减了不必要的索引和注释,只保留主干。
sql复制CREATE TABLE `t_venue` (
`id` bigint NOT NULL AUTO_INCREMENT,
`venue_name` varchar(50) NOT NULL COMMENT '球台名称',
`venue_code` varchar(32) NOT NULL COMMENT '球台编码',
`device_code` varchar(64) NOT NULL COMMENT '设备编码',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '0停用 1启用',
`price_per_hour` decimal(10,2) NOT NULL DEFAULT '20.00',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_appointment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`appointment_no` varchar(32) NOT NULL COMMENT '预约单号',
`user_id` bigint NOT NULL,
`venue_id` bigint NOT NULL,
`venue_name` varchar(50) DEFAULT NULL,
`book_date` date NOT NULL,
`start_time` time NOT NULL,
`end_time` time NOT NULL,
`status` varchar(20) NOT NULL COMMENT 'CREATED/PAID/PLAYING/SETTLING/DONE/CANCELLED/REFUNDED',
`total_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`paid_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`settlement_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`pre_auth_token` varchar(128) DEFAULT NULL COMMENT '入场控制token',
`expire_time` datetime DEFAULT NULL COMMENT '预定时段实际失效时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_appointment_no` (`appointment_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_venue_date_start_time` (`venue_id`, `book_date`, `start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这套表设计里,我用“日期 + 场地ID + 开始时间”作为并发查重索引。真正的防重复,不依赖数据库的普通唯一索引,而是靠方法级分布式锁保证同一时间同一场地只有一个创建线程能进入。
3.3 预定时段的两种校验方式
创建预约时最核心的逻辑是:不能有人在你支付的时候把同一个时段抢走,也不能在创建时检查为空,交完钱后却被别人占用。
第一种校验方式是数据库 FOR UPDATE 悲观锁,简单,但锁粒度过大会拖垮并发;第二种是“先查可用时段,再插入订单,最后提交事务”,成功率在人多时会非常低。实际项目里我更推荐把“可约时段”作为一个静态组合键加锁:lock_venue_{venueId}_{date}_{startTime},在 Redis 里对组合键加锁,抢到了才能去查数据库并插入订单。
不要小看这段设计,很多预约系统上线后出现的问题是:用户明明看到有空位,提交时却提示“已满”;或者两个人同时支付成功,后台却只能退掉一个。用 Redis 锁 + 二次检查,能在性能和准确性之间取得平衡。后面第 5.1 节的代码就是这套逻辑的可运行版本。
4. 后端核心实现:登录、支付、入场、离场结算
4.1 微信登录与手机号授权
小程序的用户体系不是普通用户名密码,而是 wx.login 换取 code,后端拿 code 去微信接口换 openId 和 session_key。拿到用户唯一标识后,服务端根据 openId 查找本地用户,查不到就创建新用户,然后签发自己的 token,后续请求统一放在 header 的 Authorization 字段。
如果用户需要绑定手机号,现在推荐使用微信官方手机号快速验证组件,拿到 phoneCode 后,再调用后端接口换取手机号。整体流程不算复杂,但有一个容易被忽略的点:微信接口换回 session_key 后,必须由服务端保存,绝不能被返回到小程序端。因为后续若要做云开发或解密用户数据,都需要这个会话密钥。
java复制@PostMapping("/login")
public Result login(@RequestBody WxLoginRequest req) {
String openId = wxService.getOpenId(req.getCode());
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getOpenId, openId));
if (user == null) {
user = new User();
user.setOpenId(openId);
userMapper.insert(user);
}
String token = JwtUtil.createToken(user.getId());
return Result.success(new LoginResp(token, user.getId()));
}
当然,这个简化版本没有处理同一微信多端同时登录的问题。若要更严格,可以在 Redis 中保存 token 的指纹,一个账号被新 token 登录后旧 token 立即失效。预约小程序的用户量不大,但这个防串号体验很值得做。
4.2 微信支付V3与转账退款
支付这块是很多初学者的玻璃天花板,其实微信支付的接入方式已经非常友好,难点主要在证书配置、回调验签、退款操作。在 Spring Boot 里推荐直接用官方 wechatpay-java SDK,不用自己写签名。
发起统一下单时要传 openId,还要注意回调地址必须是公网 HTTPS,且需要在小程序后台配置“支付回调域名”。商户平台创建单号后,小程序端 wx.requestPayment 拉起收银台。用户支付成功后,微信会异步通知后端,后端收到通知后要校验签名、解密资源内容,确认订单号金额,再更新预约单状态,同时释放支付成功后的控制凭证。
这里有一个大坑:微信支付回调可能重复上报,后端必须把订单更新操作做成幂等。我的处理方法是回调的同一时刻先查一下订单状态,只有 status = CREATED 时才允许改成 PAID。如果状态已经改变,直接返回成功,避免重复退款。
java复制@PostMapping("/notify/pay")
public String payNotify(@RequestBody String body) throws Exception {
PayNotifyResult result = wxPayService.parseNotify(body);
String outTradeNo = result.getOutTradeNo();
return transactionTemplate.execute(status -> {
Appointment app = appointmentMapper.selectOne(...);
if (app != null && "CREATED".equals(app.getStatus())) {
app.setStatus("PAID");
appointmentMapper.updateById(app);
paymentLogMapper.insert(...);
}
return "{\"code\":\"SUCCESS\",\"message\":\"成功\"}";
});
}
退款接口更需要谨慎,每次退款都要传商户退款单号,且要保存退款回调结果。申请退款后立即把自己本地订单标为“退款中”,不要在微信回调前就直接标记已退款,否则一旦退款失败,用户会以为自己已经收到钱。
4.3 自助入场控制的核心逻辑
自助入场不是发个短信验证码就完事。真正落到物理世界里,需要把订单与设备联动。我设计的流程是:预约单支付成功后,后端生成一个短暂有效的进场凭证 preAuthToken,并和设备绑定。用户到现场点击“入场”,小程序把自己的订单号、场地 ID、凭证传给后端;后端验证券有效且当前时间在预约开始前 15 分钟至预约开始后 30 分钟内,才会调用设备控制层打开照明和门锁。
控制凭证必须一次性的,使用过后立即删除;如果用户因为没有按时入场而取消了,凭证也要立即作废。Redis 中我设计了这样的缓存结构:
text复制key: entrance:token:{appointmentNo}
value: {token}
expire: 2小时
用户点击入场后,后端先从 Redis 取 token 对比,如果发现匹配、没被使用过,就调用 DeviceController.turnOnPower,成功后把订单状态改为 PLAYING,然后删除 key。万一设备超时没开灯,后端抛异常让用户看到“设备未响应”,同时标记订单为异常,由运营后台手动处理。
4.4 离场结算与超时补缴
离场结算是无人自助系统与普通预约系统的分水岭。如果用户实际打球超过了预约时段,或者没有点击离场就跑了,系统必须给运营方挽回损失。
我的策略是把订单设计为“预付订场费 + 超时补缴”混合模式。微信支付预授权能力门槛较高,实际落地时我用了变通方案:下单时先把场租费,例如订了 1 小时就是 20 元,作为“履约押金”收取;用户离场点击结算时,如果实际使用时长不超过预约时长,这笔押金全额退回;如果超时,系统把超时费用按分钟计费从押金中扣除,再将剩余部分退回。
费用计算可以这样理解:假设订场费 20 元,实际打了 1 小时 20 分钟,超出 20 分钟,而单小时费率为 20 元,则超时费用 = 20 / 60 × 20 = 6.67 元,用户应退款 13.33 元。这段算法要考虑不足 1 分钟是否算 1 分钟、晚上 22 点后是否加价等场馆规则,我建议一开始就把规则配置做成表,而不是写死在代码里。
java复制public void settle(Long appointmentNo) {
Appointment app = getOrderWithLock(appointmentNo);
if (!"PLAYING".equals(app.getStatus())) {
throw new BizException("订单状态不允许结算");
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime bookingEnd = LocalDateTime.of(app.getBookDate(), app.getEndTime());
long exceedMinutes = Duration.between(bookingEnd, now).toMinutes();
BigDecimal refundAmount = app.getPaidAmount();
if (exceedMinutes > 0) {
BigDecimal exceedAmount = calcExceedFee(app, exceedMinutes);
refundAmount = refundAmount.subtract(exceedAmount);
app.setSettlementAmount(exceedAmount);
}
wxRefundService.refund(app.getAppointmentNo(), refundAmount);
app.setStatus("SETTLING");
appointmentMapper.updateById(app);
}
如果用户没有点击离场,也没有发生退款,就需要后台守护任务来处理。每 5 分钟扫描所有 PLAYING 且当前时间已经超过预约结束时间 15 分钟仍未离场的订单,强制把订单置为“待结算”或“超时并扣除全部押金”。这块必须配合设备控制层把灯自动关掉,否则用户会一直待在场地里。
5. 可直接复用的开源代码片段
5.1 高并发下创建预约单:Redis锁+事务
这个片段最值得抄,因为“预约”业务的核心难点就是并发。代码思路很简单:创建预约前对“场地 + 日期 + 时间段”加 Redis 锁,拿到锁后再次查询数据库,确保没有冲突,然后插入订单并开启事务。业务执行完成后释放锁。
java复制public Appointment createAppointment(CreateAppointmentReq req) {
String lockKey = String.format("lock:venue:%d:%s:%s",
req.getVenueId(), req.getBookDate(), req.getStartTime());
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("系统繁忙,请稍后重试");
}
// 二次校验:数据库中同一时段是否已有支付状态订单
Long count = appointmentMapper.selectCount(...);
if (count > 0) {
throw new BizException("该时段已被预约");
}
Appointment appointment = buildAppointment(req);
appointmentMapper.insert(appointment);
return appointment;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("系统繁忙,请重试");
} finally {
if (locked) {
lock.unlock();
}
}
}
这段代码牺牲了一点点时间片冲突下的体验,但保证预约过程一定不会出现两个人都支付同一个时段的问题。要注意 Redis 锁的等待时间不要设太短,高峰期 3 秒往往不够用户微信支付前重试,我建议试 5-10 秒。这里还要避免手动删除 Redis 锁时删掉别人的锁,必须在锁值里放一个自己线程的 UUID,放在 finally 里删除时也先对比。
提示:尽量不要只依赖数据库唯一索引来防重复预约,不同业务状态下“已取消”和“已支付”的订单对同一时段的可约性不同,唯一索引反而会挡住新的预约。
5.2 入场控制码的生成与校验
java复制public String createEntranceToken(Long appointmentId) {
String token = UUID.randomUUID().toString()
.replace("-", "")
.substring(0, 6)
.toUpperCase();
// 把 token 和过期时间写入 Redis
redisTemplate.opsForValue().set(
"entrance:token:" + appointmentId,
token,
Duration.ofMinutes(120));
return token;
}
public boolean verifyEntranceToken(Long appointmentId, String inputToken) {
String realToken = redisTemplate.opsForValue()
.get("entrance:token:" + appointmentId);
return realToken != null
&& MessageDigest.isEqual(realToken.getBytes(),
inputToken.toUpperCase().getBytes());
}
用 MessageDigest.isEqual 做字符串比较,虽然这里只是一个 6 位码,但以后如果要扩展到优惠码、退款码等场景,只要代码里可能存在时序差异,都应该用常量时间比较。入场码设成 6 位大写字母数字混合,比固定弱口令要安全一些。小程序端点击入场时会自动把这个 token 随请求带上来,后台收到后再比对。
需要注意的是,这个 token 不应该出现在小程序的缓存里被无限期保存,而是应该放在页面全局参数或内存中,用户退出页面就清理,降低被截图和转发的风险。若担心用户提前在门口截图发给别人,就再加一层“只能本人微信号可用”,后台把凭证与用户 ID 绑定。
5.3 定时任务:扫描超时未入场与超时未离场
无人工值守要求系统必须能自动处理那些“人没来”或“人走了但没点结算”的异常。Spring Boot 自带的 @Scheduled 足够满足小规模场馆,不必上消息队列。
一个典型任务是:扫描当前时间超过预约开始时间 30 分钟,但订单状态仍为 PAID 的订单,自动标记为 CANCELLED,并退款。一次只处理 100 条,避免大事务把普通用户的预约请求拖慢。
java复制@Scheduled(cron = "0 */5 * * * ?")
public void timeoutHandler() {
List<Appointment> noShow
