简介:微信小程序球馆预约系统SSM后端源码案例设计,是一套适合毕业设计、期末大作业与Spring/SpringMVC/MyBatis入门练习的完整项目案例。项目以后端开发为主线,涵盖Spring依赖注入与事务管理、SpringMVC请求调度、MyBatis持久层映射、小程序WXML/WXSS界面搭建及RESTful API交互,并包含用户登录、场馆信息管理、预约记录处理等典型业务模块。压缩包共869个文件、约73.38MB,主要文件类型有java后端代码、vue前端页面、js脚本、wxml/wxss视图、sql数据库脚本及png/svg插图,目录组织清晰,便于按功能模块查阅。资源内还提供安装运行批处理脚本、备份配置等辅助内容,可帮助读者快速搭建环境并理解SSM前后端协同开发流程。目前已有356人学习,对于希望掌握企业级Java Web开发与小程序调用后端接口的初学者,是一份有实践参考价值的案例资料。
1. 微信小程序球馆预约系统的 src 布局与定位
微信搜索“球馆预约”能翻出一排小程序,但绝大多数停在了“能看不能约”的演示层面。这个标题给到的价值,不是能把预约流程点通,而是把小程序前端、SSM 后端与预约业务规则塞进了同一个压缩包,正好覆盖课程设计、毕业设计、以及想转岗做后端练手的几类需求。一个球馆预约系统,表面上是 CRUD,真正麻烦的是时段粒度怎么切、并发下单怎么防超卖、订单超时怎么释放、小程序端日期时段怎么拼装请求。代码和建表语句只是骨架,业务约束才是灵魂。适合有 Java 基础、想完整看一个预约业务如何落地的开发者;后端用它复习 Spring MVC + MyBatis,前端能在真实接口上练习 wx.request 和状态管理,各取所需。
2. SSM 后端与微信小程序之间的消息模型
2.1 这个场景为什么认准 SSM 而不是 Spring Boot
很多人拿到源码第一反应是“现在谁还用 SSM”。Spring Boot 确实是新项目的主流,但课程设计、老系统维护和“要求熟悉 SSM 框架”的岗位仍然大量存在,数据不会说谎:招聘市场上带 SSM 关键字的 JD 占比依然可观。SSM 由 Spring、Spring MVC、MyBatis 三个模块构成,三者各管一摊:Spring 管理 Bean 与事务,Spring MVC 处理 HTTP 路由,MyBatis 把 SQL 与 Java 方法映射起来。
球馆预约这类业务特别适合用 MyBatis 的 XML 方式来写。时段查询需要多表 join、预约要带条件更新(UPDATE ... WHERE status = 0),这些逻辑用注解写会显得拧巴,放到 XML 里反而一目了然。Spring Boot 虽然用@Mapper也能扫到,但 SSM 的 XML 路由更直观,尤其适合教学和答辩讲解:每一段 SQL 都能被单独拿出来提问,面试官也乐意顺着问“为什么这里用条件更新而不是先查再改”。源码里的 Mapper 文件是重点阅读对象。
2.2 前后端交互的数据结构
小程序端通过wx.request发起 HTTP 请求,后端返回统一 JSON,这是最常见的做法。统一响应体一般长这样:
{ "code": 0, "message": "ok", "data": { "orderId": 10086, "status": 0, "expireTime": "2025-06-01 14:30:00" } }code为 0 表示成功,非 0 表示业务异常,比如“该时段已被预订”或“登录态过期”。data是业务数据,前端拿到后直接渲染。不要小看这个约定,很多新手项目失败在响应格式不统一——有的接口返回{success: true},有的返回{status: 1},前端 each 接口写一套判断逻辑,后期维护成本极高。拿到源码后,先全局搜code和message两个字段,确认响应体是统一封装,再往后读。
2.3 场馆、场地、时段与订单的层级关系
球馆预约系统的领域模型呈树状:一个球馆下有多个场地,一个场地在一天内被切成若干个时段,一个时段在某一天只能被一个订单锁住。实际项目里通常设计四张核心表:场馆表、场地表、时段表、订单表。场馆表和场地表是静态数据,建好后很少改;时段表可以预生成,也可以由后端动态计算;订单表则记录每次预约的完整上下文。
这四者之间的关系是理解整个源码的钥匙。时段表存的是“可预约单元”,每天 8:00 到 22:00 按 30 分钟或 60 分钟切分,生成 28 或 14 个时段;订单表则引用场馆、场地、时段三张表的外键,再加上用户身份。一个订单对应一个场地的一个时段,这就是“锁定”的语义。读到后端 Service 里的下单逻辑时,脑子里要有这条链路:请求进来 → 校验场地是否存在 → 校验时段是否可订 → 写订单 → 把时段状态置为 1(已占用)。
3. 表结构设计与预约时段的核心约束
3.1 先看时段表,再看订单表
读源码先建库,建库先看表结构。sql目录下的脚本是理解业务规则的入口,按顺序执行即可,通常包含建库、建表、初始化数据三段。以下是一份常见的核心表结构,与源码里的设计基本对应:
CREATE TABLE `venue` ( `id` int NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '球馆名称', `address` varchar(255) DEFAULT NULL, `open_time` varchar(32) NOT NULL DEFAULT '08:00', `close_time` varchar(32) NOT NULL DEFAULT '22:00', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='球馆表'; CREATE TABLE `court` ( `id` int NOT NULL AUTO_INCREMENT, `venue_id` int NOT NULL, `court_no` varchar(16) NOT NULL COMMENT '场地编号,如A01', `court_type` tinyint NOT NULL DEFAULT '0' COMMENT '0-普通场 1-VIP场', `price_per_hour` int NOT NULL DEFAULT '0' COMMENT '价格,单位:分', PRIMARY KEY (`id`), KEY `idx_venue` (`venue_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地表'; CREATE TABLE `booking_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` int NOT NULL COMMENT '用户ID', `court_id` int NOT NULL, `book_date` date NOT NULL COMMENT '预订日期', `start_time` varchar(16) NOT NULL COMMENT '开始时间 HH:mm', `end_time` varchar(16) NOT NULL COMMENT '结束时间 HH:mm', `amount` int NOT NULL COMMENT '实付金额,单位:分', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-已下单 1-已取消 2-已入场 3-已完成', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_expire_time` datetime DEFAULT NULL COMMENT '支付截止时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_court_slot` (`court_id`, `book_date`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';建表脚本里最值得看的是那条唯一索引:uk_court_slot(court_id, book_date, start_time)。它从数据库层面卡死了“同一场地、同一天、同一开始时间只能有一条订单记录”,这是防并发超卖的第一道防线。价格字段用int存“分”,而不是用decimal存“元”,避免浮点误差——这是电商类项目的通用习惯,源码里如果出现BigDecimal属于合理防御,直接用double的反而要留意。时间字段用varchar存HH:mm,可以简化前端传参与后端比较逻辑,粒度控制在分钟级完全够用。
3.2 不加锁的并发查询会出什么问题
最直观的并发事故是:用户 A 和用户 B 同时看中了周三晚上 19:00 的 1 号场地,两个请求同时进来,各自执行“查订单表发现没有冲突”,然后同时插入订单。如果没有唯一索引兜底,就会产生两条重叠的订单。
常见做法是先查再插,这种写法在低并发下没问题,但压测一上来就会暴露。推荐的做法是:先执行条件插入,再根据影响行数判断是否成功。也就是说,不要在 Service 里写“if 时段空闲 then 插入”,而是直接执行插入,让数据库的唯一索引去拦截冲突,应用层捕获DuplicateKeyException后转为“该时段已被预订”的提示返回给用户。既省一次查询,又天然抗并发。
唯一索引还有一个好处:超时释放后可以安全重试。用户下单后 15 分钟内未支付,定时任务把订单状态改成已取消,同时释放时段。由于订单记录还在,下一个用户再下单时,如果恰好与已取消订单的时段相同,唯一索引会阻止插入——这正好说明业务上需要同时处理订单状态和索引冲突两个维度,不能只依赖任意一个。
3.3 时段粒度与不可用时段生成
时段粒度的选择直接决定表数据量和查询复杂度。30 分钟粒度比较常见:每天 8:00 到 22:00 共 14 小时,切成 28 个时段。60 分钟粒度适合羽毛球场,篮球场按小时包场更合理。源码里通常是在CourtService或BookingService里写一个生成方法,入参是venue_id、book_date、court_id,返回当天的可预约时段列表。
实现方式一般有两种。第一种是提前生成court_slot表,每个场地每天 28 条记录,字段含status(0-可约,1-已锁定,2-已过期),下单时直接更新状态;第二种是运行期计算,把已存在的订单时段从完整时段集合里排除。课程设计级别的源码大多用第一种,因为好理解、SQL 简单;生产级系统更倾向第二种,少一张表少一份一致性维护成本。拿到源码后先看它是哪种实现,后面写接口时的思路完全不同。
4. 后端接口落地:从 code 换 token 到下单扣减
4.1 登录态:小程序 code 换取 openid 与自定义 token
微信小程序没有传统意义上的账号密码,登录流程是:小程序端调用wx.login()拿到临时code,把code传给后端;后端用code+appid+appsecret调用微信的code2Session接口,换取openid和session_key;后端用openid查询或创建用户,并签发一个自定义 token 返回给小程序。后续所有请求都在 header 里带Authorization: Bearer <token>,后端通过拦截器解析 token,确定当前用户身份。
这里有个容易被忽略的安全点:code2Session必须由后端调用,微信接口的appsecret绝不能暴露在小程序代码里。源码里如果直接在wx.request的 URL 中写死了含appsecret的链接,属于严重设计缺陷。
后端签发 token 可以自己生成 UUID 存 Redis,也可以用 JWT。课程设计级别的源码大多没有引入 Redis,而是用ConcurrentHashMap做内存缓存,配合拦截器做校验。生产环境会换成 Redis 并设置过期时间,但源码阅读阶段,重点看拦截器如何从 header 取 token、如何校验、如何把userId塞进ThreadLocal或 request attribute,供后续 Controller 取用。代码大致长这样:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } if (token == null || !TokenManager.valid(token)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"登录态失效\"}"); return false; } Integer userId = TokenManager.getUserId(token); request.setAttribute("userId", userId); return true; } }AuthInterceptor继承HandlerInterceptor,重写preHandle方法做登录校验。TokenManager.valid(token)负责判断 token 是否存在且未过期;校验通过后,把userId放到 request 属性里,Controller 就能通过@RequestAttribute("userId")直接拿到。注意,这里不要自己解析 openid,也不要把 openid 暴露给前端,前端只需要知道“我是谁”由后端说了算即可。
4.2 下单接口的参数校验与业务规则
下单接口是整套源码里逻辑最重的一块,一般长这样:
@PostMapping("/order/create") public Result createOrder(@RequestBody @Valid CreateOrderRequest req, @RequestAttribute("userId") Integer userId) { if (!DateUtil.isBookableDate(req.getBookDate())) { return Result.error("仅支持预订未来7天内场地"); } if (req.getStartTime().compareTo(req.getEndTime()) >= 0) { return Result.error("开始时间必须早于结束时间"); } try { BookingOrder order = bookingService.createOrder(userId, req); return Result.success(order); } catch (BookingConflictException e) { return Result.error("该时段已被其他用户预订,请换个时间"); } }CreateOrderRequest通常包含courtId、bookDate、startTime、endTime四个字段,注解校验@NotNull保证必填,业务规则校验则负责日期范围和时间先后。bookingService.createOrder内部会处理金额计算、订单号生成、订单插入等操作。金额计算逻辑一般在CourtMapper里查出price_per_hour,然后按小时折算,不满一小时按一小时计费,这部分每个项目策略略有不同,源码里通常注释得比较清楚。
下单成功返回的不是简单的“成功”提示,而是带着orderId和payExpireTime,小程序端据此启动 15 分钟倒计时。订单号一般用日期 + 随机数生成,避免暴露自增 ID,防止被别人遍历订单号。
4.3 防超卖:条件更新配合事务回滚把并发问题挡在 SQL 层
核心思路是让数据库自己判断冲突。在BookingService中,下单操作分两步:第一步先利用唯一索引插入订单,第二步执行一条带条件的状态更新:
UPDATE court_slot SET status = 1 WHERE court_id = #{courtId} AND book_date = #{bookDate} AND start_time = #{startTime} AND status = 0这条 SQL 的关键在最后AND status = 0。执行后返回的影响行数如果为 1,说明当前时段确实是空闲的且已被本事务抢到;如果为 0,说明该时段已经被其他请求修改过了,直接抛出BookingConflictException。整个过程包在同一个事务里,任何一步失败,订单插入与状态更新一起回滚,不会出现“订单建了但场地没锁住”的中间状态。
代码层面的关键点有两个。第一,update方法写在 Mapper XML 里,返回值是int,Service 层必须检查返回值,不能忽略;第二,@Transactional一定要加在createOrder方法上,并要注意自调用问题——同一个类里this.createOrder调用会让事务注解失效,Spring 的声明式事务默认通过代理类生效。源码里如果事务莫名没生效,先检查是不是同类内部方法调用,这几乎是面试必问的坑。
4.4 状态流转与超时释放
订单状态通常定义在OrderStatusEnum中,简单的实现用整型常量:0-已下单、1-已取消、2-已入场、3-已完成。已下单状态有一个支付截止时间,定时任务每分钟扫描一次,把超过pay_expire_time且状态仍为 0 的订单批量更新为已取消状态,同时释放场地锁定。
定时任务在 SSM 项目里可以通过 Spring 的@Scheduled注解实现,需要在配置类上加上@EnableScheduling。执行频率不建议太频繁,每 60 秒扫一次即可,SQL 大概是:
UPDATE booking_order SET status = 1 WHERE status = 0 AND pay_expire_time < NOW()注意,要更新订单状态为已取消,并同步释放场地时段。这个任务的核心价值在于:用户锁单后放鸽子,场地不能永远被占着。源码里如果任务逻辑简单,很可能只改了订单状态而忘了释放时段,这是常见的不完整实现。检查源码时,重点看定时任务里是否有第二步——把该订单对应的时段状态改回 0。两个操作必须在同一事务中执行,否则会出现订单已取消但场地仍不可订的问题。
5. 微信小程序端对接与页面实现
5.1 首页场馆列表与场地选择
小程序端通常包含三个主页面:首页场馆列表、场地详情与时段选择、订单确认与支付状态。首页的场馆列表通过wx.request请求后端/venue/list接口,拿到 JSON 数组后用wx:for渲染卡片。这里有个实践细节:请求前先拼接完整 URL,base URL 放在app.js的globalData里,而不是散落在每个页面。
场馆详情页进入后,会请求/venue/detail?id=xx和/court/list?venueId=xx两个接口,分别拿场馆信息和场地列表。场地卡片上需要展示当前时段的可约状态,通常是加载页面时一次性拉取当天全部时段,前端根据时段状态渲染绿色“可约”、灰色“已约满”、黄色“待支付”三种视觉状态。不要把状态判断逻辑写死在小程序里,后端返回的status字段才是唯一依据。
5.2 请求封装与 token 注入
小程序端的utils/request.js是核心封装模块,通常长这样:
const BASE_URL = 'https://your-domain.com/api'; function request(path, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + wx.getStorageSync('token') }, success(res) { if (res.statusCode === 401) { wx.navigateTo({ url: '/pages/login/index' }); return; } if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message, icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }BASE_URL换成实际后端地址,上线时注意微信小程序后台要配置合法域名。wx.getStorageSync('token')每次请求从缓存取 token 注入 header,登录态失效时统一跳转登录页。这段封装的逻辑是:任何接口的 401 都视为登录过期;业务上的错误码统一以res.data.code为准,不让业务判断散落在每个页面的 success 回调里。
首次登录时,页面调用wx.login()获取 code,传给后端/auth/login,后端返回 token,小程序端存入 storage。要点是wx.login拿到的 code 有效期只有 5 分钟,而且只能用一次,不能缓存复用;每次进入小程序应当重新获取。源码里如果看到登录页写死了 code 或者把 code 存在全局变量里,都属于值得修正的坏味道。
5.3 日期选择与时段列表的动态渲染
预约页面的日期选择器是业务交互难点。通常默认展示今天和未来 6 天,点击日期重新请求当天时段价格与状态。时段列表用scroll-view横向或纵向排列,按时间升序渲染。渲染时把后端返回的时段数组直接setData,不要在前端做二次裁剪,避免出现“前端过滤了状态不可约时段,但总数对不上”的问题。
比较稳妥的交互逻辑是:默认只展示“可约”状态的时段,用户点选某时段后弹出场地详情与价格确认框,确认后调用/order/create创建订单。订单创建成功后进入待支付状态,前端启动 15 分钟倒计时,倒计时结束如果未支付,轮询订单状态接口确认是否被释放,并在界面更新时段状态。注意,不要在倒计时归零后只做本地状态修改,服务端的定时任务可能还没跑到,要以订单查询接口的返回为准。
5.4 防止重复下单的前端策略
后端有唯一索引兜底,但前端也要避免用户狂点按钮产生多个订单。最简单的手段是给按钮加“下单中”状态,请求发出后禁用按钮,收到响应后恢复。另一种做法是提交前通过wx.showLoading加遮罩,禁止重复点击。
但纯前端防重不可靠,真正的兜底必须落在后端:同一时间段下订单,幂等性可以通过请求唯一号或 userid + 时间窗口控制。课程设计级别的源码通常是后端唯一索引承担全部压力,前端只负责降低误触率,这个职责划分是合理的。
6. 验证与排错:把源码跑起来以后要做的事
6.1 用抓包工具验证请求链路
启动后端服务后,先用小程序开发者工具跑通“登录 → 场馆列表 → 场地详情 → 创建订单”这条主链路。开发者工具自带的 Network 面板可以直接查看请求头、请求体、响应体,重点确认三件事:请求 URL 是否正确拼接了/api前缀,Authorization头有没有带上 token,响应里的code字段与页面提示是否一一对应。
如果域名还没配 HTTPS,开发者工具里勾选“不校验合法域名”即可,但真机预览时必须在小程序后台配置 request 合法域名。后端日志里重点看 MyBatis 打印的 SQL(在application.properties或mybatis-config.xml里开启log-impl: StdOutImpl),核对下单 SQL 的条件court_id、book_date、start_time是否正确拼入,有没有走uk_court_slot唯一索引。SQL 与预期一致但插入失败,再排查索引字段是否与代码传入的字段完全一致,比如book_date是java.sql.Date还是String,start_time是否带了空格。
6.2 并发下单场景的验证方法
后端启动后,打开两个浏览器窗口分别登录不同微信号,同时点击同一个场地同一个时段的“立即预约”。正常情况下只有一个窗口能下单成功,另一个窗口收到“该时段已被其他用户预订”的提示。如果两个都成功,问题出在唯一索引没生效或事务回滚不彻底,先检查订单表的索引是否真的建上了,再用下面的 SQL 验证:
SELECT court_id, book_date, start_time, COUNT(*) FROM booking_order WHERE status IN (0, 2, 3) GROUP BY court_id, book_date, start_time HAVING COUNT(*) > 1;如果查询有结果,说明表中存在重复时段订单,唯一索引漏建或字段不一致。用SHOW INDEX FROM booking_order;确认索引状态,如果索引存在但重复数据仍能插入,多半是status字段影响了索引设计——有些设计会把唯一索引放在(court_id, book_date, start_time, status)上,这样同一时段被取消后再下单虽然不冲突,但历史数据里会存在多条同字段、不同状态的数据,这种设计需要改代码逻辑来兜底,不如单一唯一索引干净。
6.3 定时释放的验证与常见异常
验证超时释放功能,把某个订单的pay_expire_time手动改成过去时间,然后等待定时任务执行,观察订单状态是否变为已取消、对应时段是否恢复可约。一个常见异常是:订单状态更新了,但时段状态没变,导致场地永久被锁。此时检查定时任务的方法上是否缺少@Transactional,以及两条 SQL 是否在同一个方法里。
另一个高频异常是 MyBatis 返回的 LocalDateTime 序列化失败,后端能查到数据但接口返回 500。原因大多是项目里引了 Jackson 但没注册 JavaTimeModule,解决方式有两种:把实体里的日期字段改成java.util.Date,或者在spring-mvc.xml里配置ObjectMapper并注册JavaTimeModule。最省事的是保持String类型接收和返回yyyy-MM-dd HH:mm:ss,预约业务不需要前端做复杂的日期运算,字符串反而少踩坑。
6.4 查时段列表的 N+1 查询优化
场地详情页加载时,如果做法是“先查场地列表,再循环查每个场地的时段”,就会产生 N+1 次查询。场馆少时看不出来,场地一多接口就明显变慢。优化方式是换成一次 join 查询:
SELECT c.id AS court_id, c.court_no, s.slot_time, s.status FROM court c LEFT JOIN court_slot s ON s.court_id = c.id AND s.book_date = #{bookDate} WHERE c.venue_id = #{venueId} ORDER BY c.court_no, s.slot_time;后端拿到扁平结果集后,在 Service 层按courtId分组组装成嵌套结构再返回给小程序。这样查询从 N+1 次降为 1 次,小程序端拿到“场地列表 + 每场地的时段状态”后一口气渲染,交互也更跟手。验证优化是否生效,看后端日志里打印的 SQL 条数,一条 join 替代 N 条独立查询即为成功。
本文还有配套的精品资源,点击获取