尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信小程序球馆预约系统:SSM后端与并发防超卖实战解析

微信小程序球馆预约系统:SSM后端与并发防超卖实战解析 简介微信小程序球馆预约系统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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 条记录字段含status0-可约1-已锁定2-已过期下单时直接更新状态第二种是运行期计算把已存在的订单时段从完整时段集合里排除。课程设计级别的源码大多用第一种因为好理解、SQL 简单生产级系统更倾向第二种少一张表少一份一致性维护成本。拿到源码后先看它是哪种实现后面写接口时的思路完全不同。4. 后端接口落地从 code 换 token 到下单扣减4.1 登录态小程序 code 换取 openid 与自定义 token微信小程序没有传统意义上的账号密码登录流程是小程序端调用wx.login()拿到临时code把code传给后端后端用codeappidappsecret调用微信的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;charsetUTF-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 里返回值是intService 层必须检查返回值不能忽略第二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渲染卡片。这里有个实践细节请求前先拼接完整 URLbase URL 放在app.js的globalData里而不是散落在每个页面。场馆详情页进入后会请求/venue/detail?idxx和/court/list?venueIdxx两个接口分别拿场馆信息和场地列表。场地卡片上需要展示当前时段的可约状态通常是加载页面时一次性拉取当天全部时段前端根据时段状态渲染绿色“可约”、灰色“已约满”、黄色“待支付”三种视觉状态。不要把状态判断逻辑写死在小程序里后端返回的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还是Stringstart_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 查时段列表的 N1 查询优化场地详情页加载时如果做法是“先查场地列表再循环查每个场地的时段”就会产生 N1 次查询。场馆少时看不出来场地一多接口就明显变慢。优化方式是换成一次 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分组组装成嵌套结构再返回给小程序。这样查询从 N1 次降为 1 次小程序端拿到“场地列表 每场地的时段状态”后一口气渲染交互也更跟手。验证优化是否生效看后端日志里打印的 SQL 条数一条 join 替代 N 条独立查询即为成功。本文还有配套的精品资源点击获取
返回列表