
做场馆预约系统的同学应该都知道真正让人头痛的从来不是“能用小程序下单”而是把线下球馆改造成无人值守的完整闭环。这篇文章要聊的是一套用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 MapInteger, SetInteger 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) { SetInteger 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 参数例如sceneorderNo这样用户扫码时微信会打开小程序的签到页页面再从 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 定时任务和数据库时区问题处理超时关单、自动完成、爽约标记时后台一般会写几个定时任务。最容易碰到的问题是开发机器和服务器时区不一致导致订单日期偏移。比如你数据库连接参数里没写serverTimezoneAsia/ShanghaiMySQL 默认会话时区可能是 UTC 或系统时区Java 写入的 LocalDateTime 和数据库显示的时间自然会差 8 小时。更隐蔽的是用户约了明天的 18:00定时任务判断“当前时间 预约开始时间”时会误判成已超时。我的建议是两件事做在前面第一MySQL JDBC URL 显式加serverTimezoneAsia/Shanghai同时 Linux 服务器时间用 timedatectl 设置成 Asia/Shanghai第二所有时间字段在 Java 中用LocalDateTime或OffsetDateTime不要用Date混用。这两个习惯养成后时间类 bug 能少掉一大半。6.5 小程序码 scene 参数超长用wxacode.getUnlimited生成入场小程序码时scene参数有一个 32 个可见字符的长度限制。如果直接传订单号可能超长。解决办法是给订单号创建对应的短码二维码 scene 里只带短码短码可以存到 Redis过期时间和订单有效周期一致也可以直接存订单表加一个short_code字段。用户扫码后小程序端解析 scene 里的短码再用短码查真实订单号。这个坑不复杂但很容易在上线前踩到我见过不少团队临时把二维码换成普通二维码才绕过去。还有一种方案是直接用一张后端生成的普通二维码图片把订单号写进 URL 参数。但微信普通二维码在微信内识别时体验和跳转小程序码不完全一样对“用户扫码后打开小程序签到页”的流程不太友好。所以如果目标是把链路全部放在小程序内还是尽早把短码机制设计好。最后再分享一点实际感悟乒乓球馆无人自助这类项目表面上是在写预约系统实际做到后面会发现它是在做“履约闭环”。用户在小程序里完成的每一步都会映射到线下的一个资源变化。无论你接的是门禁、灯光还是发球机设计原则都是一样的让线上订单状态成为唯一事实来源让每一次签到、支付、取消都有据可查。只有这样无人场馆才能真正减少人工介入而不是把原来前台的工作量转移到线上客服去处理。如果你准备基于这套方案落地我的建议是先选一家场地做小范围试运行把预约、支付、签到、门禁全部接通后再逐步把信用分、报表、多门店等锦上添花的功能加上来。代码结构上按“场地-时段-订单-状态流水-设备任务”这条主线去建表后面扩展场景基本不用推翻重来。等到系统上线跑了两周你会越发理解为什么数据库表、状态机和并发控制这几块值得在最开始就认真设计。