1. 项目拆解:健身房预约小程序到底在做什么
先把这个项目的本质说清楚。表面看它是一个“健身房预约小程序”,有源码、数据库和文档这三件套,但剥开外壳,它其实是典型的多端联动的业务系统:用户通过微信小程序端浏览教练、查看空闲时段、提交预约;管理员通过Vue搭建的Web管理后台维护教练信息、排班、课程和订单;中间由Java后端(Spring Boot框架最常用)提供数据接口,MySQL负责持久化存储。这三部分各司其职,缺一不可。
为什么非得是“Java + Vue + 小程序”这种组合?因为成本和效率最平衡。Java(Spring Boot)在后端生态里属于“居家必备”的选择,稳定、资料多、招人容易;Vue做管理后台开发速度快,组件生态完善,Element UI一套下来界面就很像样;小程序端则直接命中用户“即用即走”的使用习惯——预约这种低频操作,让用户为了订一节操课去下载安装App,那转化率基本凉凉。所以这个技术组合不是炫技,而是围绕业务场景做出的自然选择。
那这个系统解决了什么问题?健身房的真实痛点其实很具体:
- 教练时间冲突:一个教练同一天被约了三四次,线下靠Excel记录,迟早乱成一锅粥。
- 用户到场率低:电话预约随口答应,到点人没来,教练干等,器械空置。
- 高峰期排队:高峰期器械和团课都紧张,没有预约机制,用户体验差。
- 会员管理粗放:办卡、次卡、剩余次数全靠收银员脑子记,换个人就说不清。
预约系统就是把“教练时间”、“器械名额”、“会员卡次”这三块资源统一成一个在线可管理、可查询、可结算的状态机。这是所有预约类系统的核心逻辑:资源和时间的绑定关系。健身房如此,理发店、美容院、自习室其实一个道理。
从交付物来看,源码解决“怎么改”、数据库文件解决“怎么建”、文档解决“怎么跑起来、怎么联调、怎么部署”。对于一个毕业设计或者中小型商业项目来说,这是标准的三件套,我后面会把每部分的关键细节都过一遍。
2. 技术选型的背后逻辑与项目整体架构
2.1 为什么选 Spring Boot + MyBatis Plus 而不是 SSM 手写
很多早期的Java项目资料还在用SSM(Spring + SpringMVC + MyBatis)那套XML配置,新项目我强烈建议直接上Spring Boot。别误会,不是SSM学不会,而是Spring Boot把绝大多数繁琐配置变成了“约定优于配置”,比如内嵌Tomcat、自动化装配、起步依赖,这些能省掉初级开发一两天的环境搭建时间。尤其是一个健身房预约系统,业务复杂度远没有达到需要手写大量配置的程度,把时间花在业务逻辑上更值。
持久层我个人推荐MyBatis Plus而不是纯MyBatis。核心原因是它的单表CRUD能力太省事了:BaseMapper里已经封装好selectById、selectList、insert、updateById这些方法,健身房系统里教练管理、会员管理、时段管理几乎全是单表操作,用MyBatis Plus能省掉大量重复的SQL手写。遇到多表联查(比如预约列表带出教练姓名和课程名)再写XML里的resultMap自定义映射也不迟。
Vue这边没什么争议,管理后台用Vue 2 + Element UI最稳妥。Vue 3虽然性能好且有Composition API,但Element UI的生态和资料成熟度明显更高,遇到问题随便一搜就有解决方案,这对开发效率的影响其实是决定性的。管理系统不需要花哨动画,表格、表单、日期选择器、弹窗确认,Element UI全是现成的。
2.2 整体架构:一个系统分成三段,边界要清晰
整个项目建议按经典的前后端分离模式组织,目录上分成backend、admin-web、miniapp三块:
ginas-app/ ├── backend/ # Spring Boot 后端,提供 RESTful API ├── admin-web/ # Vue 管理后台,给健身房运营人员用 ├── miniapp/ # 微信小程序端,给C端用户用 └── sql/ # init.sql(建库建表)、data.sql(种子数据)后端模块划分建议按业务域分包,不要按技术类型硬拆(所有controller扔一个包、所有service扔一个包是最坑的做法)。按member(会员)、coach(教练)、course(课程)、booking(预约)、admin(后台管理)分包,每个包内含controller、service、mapper、entity。这样后面加功能的时候,改动范围非常清晰。
关于接口风格,我只说一点:前后端分离项目务必用统一返回体Result<T>,包含code、msg、data三个字段。万一某个接口要返回的字段是从null变成空数组,或者从200变成40001,你不需要动前端调用方的解析逻辑。这是我用血泪换来的经验——早期项目直接返回Map或裸数据,接口一升级前端就崩。
2.3 小程序端:原生还是 uni-app
Vue和小程序的结合通常有两条路:一是小程序端完全用原生(WXML + WXSS + JS)开发,管理后台用Vue;二是小程序端用uni-app这种Vue语法跨端框架开发。两种方案都有人用,取舍很简单。
原生小程序的好处是性能最优、调试方便、调试工具的能力能100%发挥(比如真机预览、性能面板、wxml节点审查),但代码复用到其他平台(支付宝小程序、抖音小程序)基本没戏。uni-app则把Vue 2的语法直接搬进小程序,开发体验更贴近Vue,一套代码能发布到多个平台,网上模板也特别多。
我这个项目里用的方案是原生小程序为主,因为功能复杂度不高,涉及的核心页面就首页、预约页、订单页和个人中心,原生小程序的Page生命周期足够承载这些逻辑。但如果你本身对Vue更熟,用uni-app一样能把项目跑起来,这取决于你自己更顺手哪种。这里不做强制推荐,说清楚差异让你自己判断。
3. 数据库设计:预约系统的地基,表结构到底怎么建
3.1 核心表拆解:9张表覆盖全部业务
健身房预约系统的数据量远没有电商那么大,但表之间的关系约束做不好,后期改起来真是想哭。我比较推荐这套表设计(根据实际项目做微调即可):
| 表名 | 用途 | 关键字段说明 |
|---|---|---|
member | 会员表 | openid(微信唯一标识)、nickname、phone、balance(余额,单位分)、status |
coach | 教练表 | name、avatar、specialty(擅长领域)、intro、status |
course | 课程表 | name、cover、type(团课/私教)、duration(分钟)、capacity(人数上限)、price |
class_schedule | 排课表 | course_id、coach_id、start_time、end_time、date、remaining(剩余名额) |
booking_order | 预约订单表 | order_no、member_id、schedule_id、status(0待支付/1已确认/2已完成/3已取消)、amount |
member_card | 会员卡表 | member_id、card_type(次卡/月卡)、total_times、used_times、expire_time |
payment_log | 支付流水表 | order_id、pay_type、pay_amount、transaction_id(微信支付单号)、status |
admin_user | 后台管理员表 | username、password(BCrypt加密)、role |
notice | 公告表 | title、content、publish_time、status |
这里有几个设计细节值得单独说。
金额一律存“分”。数据库里凡是涉及钱的字段,都用整数存储(如balance存的是分),避免浮点误差。显示的时候前端转换成“元”,后端返回给前端也应该是字符串形式的“分”,不要让JSON序列化把大整数变科学计数法——一个1200分的余额变成1.2E3,前端展示直接裂开。这是所有交易系统的共识。
订单号用时间戳 + 随机数,不要用自增ID做对外单号。自增ID会暴露业务量(今天第10000个订单,明天第10001个),也不方便做并发去重。直接SimpleDateFormat格式化时间戳,加几位随机数,生成如yyyyMMddHHmmss+6位随机的订单号,前置一条唯一索引。这样既防了重复单号,也符合微信支付对商户订单号的字符要求。
class_schedule表要加唯一索引。这句最重要。UNIQUE KEY uk_coach_time (coach_id, start_time),意思就是同一个教练在同一个开始时间只能有一条排课记录。这个索引是防止“教练撞档”的数据库层面兜底,哪怕代码里忘记判断冲突,数据库也会拒绝插入。预约系统的可靠性很大程度上依赖这类数据库约束,而不是单纯靠应用层if判断。
3.2 预约状态流转:从待支付到已完成,一张状态机图讲清楚
预约订单的status字段是整个系统的灵魂。建议设置4种状态,并且在前端文档里明确状态流转规则:
0(待支付) → 1(已确认/已约) → 2(已完成/已核销) ↘ 3(已取消,由用户主动取消或超时未支付系统自动取消)- 0 → 1:用户提交预约且支付成功(或者免费预约场景下直接确认)。
- 1 → 2:用户到店,教练或前台在管理后台点击“核销”,标志着次卡扣减或课程完成。
- 3:取消后不回流,不允许从取消状态变成已确认。因为名额一旦释放就可能被别人占了。
- 2 不可逆:完成状态的订单不能再取消,不然一个用户每次上完课都取消,规则就形同虚设。
这种状态机的好处是可审计:任何时候打开订单列表,看到状态4就知道订单的全生命周期到哪一步了。前端小程序端的状态显示也是映射到这几档:待支付显示“去支付”、已确认显示“已预约”、已完成显示“已完成”、已取消显示“已取消”。
这里有一个容易踩的坑:取消预约必须释放名额。你就想,用户取消了,但排课表的remaining还是少的,那其他想约的会员永远约不上。所以取消操作要做两件事:第一步更新订单状态为3,第二步把排课表remaining加回来。这两步必须在同一个事务里执行,否则就会出现订单取消了、名额没释放的bug。数据库事务就是为了这一类场景存在的。
-- 释放名额的语句(要在事务中执行) UPDATE class_schedule SET remaining = remaining + 1 WHERE id = #{scheduleId};3.3 并发预约的核心矛盾:最后一个名额怎么别超卖
预约系统最容易出现的线上事故是什么?超卖。100个会员同时抢最后一个瑜伽课名额,最后10个人都显示“预约成功”,到店后现场一片混乱。千万别以为这是电商秒杀才要防的事,预约系统的并发量级虽然小,但并发问题一样存在,哪怕一天就10个人同时点提交,也可能撞车。
防超卖有三种常见做法,按可靠程度排序:
方案一:数据库行锁(最简单可靠)
预约时不是先查询再更新,而是直接用带条件的UPDATE语句:
-- 先尝试扣减名额,affected rows = 1 说明扣减成功 UPDATE class_schedule SET remaining = remaining - 1 WHERE id = #{scheduleId} AND remaining > 0;影响行数为1,才继续创建订单;影响行数为0,说明没名额了,直接返回“已约满”。这个方案之所以可靠,是因为数据库的UPDATE操作自带行级锁,同一时刻只有一个事务能成功执行这条语句,天然避免了超卖。这也是我目前最推荐的做法。
方案二:乐观锁(版本号控制)
在class_schedule表加一个version字段,更新的时候带上旧版本号:
UPDATE class_schedule SET remaining = remaining - 1, version = version + 1 WHERE id = #{scheduleId} AND version = #{oldVersion};一旦版本号对不上,说明有人已经改过这行数据了,本次更新失败,重试或提示用户“名额紧张,请刷新后重试”。这个方案比行锁多一点代码量,好处是连接不用一直持有行锁,性能更好。
方案三:Redis分布式锁(并发量特别高时才需要)
用SET key value NX EX拿锁,拿到锁才执行扣减,执行完释放锁。这方案并发几千上万才刚需,健身房预约这个量级,属于杀鸡用牛刀,还会引入Redis运维成本和锁超时问题。我直接不推荐。
真正的做法是:方案一为主,方案二作为排课修改时的升级预留。第一版系统完全可以用方案一撑住,后续如果做活动引流、早起扫码抢课,再考虑上Redis不迟,因为表结构已经预留了版本号的余地。
4. 后端Java核心接口的代码级讲解
4.1 登录接口:微信登录与Token签发
用户端小程序登录走的是微信官方code2session接口:前端调用wx.login()拿到临时code,传给后端;后端拿着code、appid、secret调用微信接口换openid和session_key。这个流程看起来简单,但有个坑:不能把openid直接当登录凭证用在后端和前端之间的鉴权上。因为openid仅仅是用来标识用户身份的,真正的会话凭证应该由后端自己签发一个Token返回给前端,以后前端每次请求都带这个Token。
Token建议直接用jwt(JSON Web Token),把memberId和过期时间塞进去,用HMAC-SHA256签名。登录流程简化为:后端拿到code换取openid→ 查询member表有没有该openid,没有就自动注册一个默认会员 → 用memberId签发JWT → 返回token和memberId给前端。
@PostMapping("/api/auth/login") public Result<LoginVO> login(@RequestBody LoginDTO dto) { // 1. 调用微信接口获取 openid String openid = wechatService.code2Session(dto.getCode()); // 2. 查找或创建会员 Member member = memberMapper.selectOne( new LambdaQueryWrapper<Member>().eq(Member::getOpenid, openid)); if (member == null) { member = new Member(); member.setOpenid(openid); member.setNickname("微信用户" + openid.substring(0, 6)); member.setStatus(1); memberMapper.insert(member); } // 3. 生成JWT,有效期7天 String token = jwtUtil.generateToken(member.getId(), member.getNickname()); // 4. 返回 return Result.success(new LoginVO(token, member.getId())); }微信的临时code有效期只有5分钟,且只能使用一次。所以拿到code后要尽快调接口换openid,不要做任何耗时的中间处理。
服务端存放JWT密钥时,不要写在代码里,放在application.yml配置文件中,并且生产环境通过环境变量注入。密钥一旦泄露,别人就能伪造任意会员身份伪造Token,这一点怎么强调都不过分。
4.2 预约操作的核心Service逻辑:事务 + 防超卖
提交预约是整个系统技术含量最高的接口。我来贴一段完整的关键代码,把事务、防超卖、状态机一次性串起来:
@Override @Transactional(rollbackFor = Exception.class) public Result<BookingOrderVO> createBooking(BookingCreateDTO dto, Long memberId) { // 1. 校验会员状态和余额/次数 Member member = memberMapper.selectById(memberId); if (member == null || member.getStatus() != 1) { return Result.fail("会员不存在或已禁用"); } // 2. 查询排课,校验课程状态 ClassSchedule schedule = scheduleMapper.selectById(dto.getScheduleId()); if (schedule == null || schedule.getStartTime().before(new Date())) { return Result.fail("该课程已开始或已下架"); } // 3. 通过行锁防超卖:扣减名额 int affected = scheduleMapper.decreaseRemaining(schedule.getId()); if (affected == 0) { return Result.fail("课程名额已满,请选择其他时段"); } // 4. 生成订单号 String orderNo = generateOrderNo(); // 5. 计算金额(可以从course表带出) BookingOrder order = new BookingOrder(); order.setOrderNo(orderNo); order.setMemberId(memberId); order.setScheduleId(schedule.getId()); order.setCourseId(schedule.getCourseId()); order.setCoachId(schedule.getCoachId()); order.setAmount(schedule.getPrice()); order.setStatus(0); // 待支付 bookingOrderMapper.insert(order); // 6. 返回订单信息,前端拿着去调支付 return Result.success(new BookingOrderVO(orderNo, order.getAmount())); }这段代码有四个关键点需要仔细理解:
事务:@Transactional保证decreaseRemaining和insert要么都成功要么都失败。如果订单插入失败,名额扣减也会回滚,不会出现“名额少了但没订单”的情况。
decreaseRemaining的SQL长这样,注意这段SQL必须自动带remaining > 0这个条件,防止扣成负数:
<update id="decreaseRemaining"> UPDATE class_schedule SET remaining = remaining - 1 WHERE id = #{id} AND remaining > 0 </update>并发场景:多个请求同时执行这条UPDATE时,数据库行锁会让它们排队执行,只有一个请求的remaining > 0条件成立,其他请求affected为0,直接返回约满。这比“先select再update”高了一个安全级别。
状态机:新订单状态是0(待支付),同时也就意味着——预约名额其实在“待支付”那一刻已经锁定了。这引出了下一个问题:用户一直不支付怎么办?名额不该一直被占着,所以需要超时取消机制。
4.3 超时未支付自动取消:定时任务 vs 延迟队列
“待支付订单锁死名额”的设计是合理的,因为用户在支付流程中可能正输密码呢,名额就被别人抢了,体验差。但名额不能无限期占用,需要给待支付订单加一个有效期(比如15分钟)。
最朴素的方案是Spring的@Scheduled定时任务,每分钟扫描一次status = 0且create_time超过15分钟的订单,执行取消。好消息是配合之前decreaseRemaining的思路,释放名额也能复用行锁更新,不会出并发问题。
定时任务的代价是时间不精确(最差情况延迟59秒),且每分钟全表扫描一次消耗不大,这个量级完全够用。
更优雅的方案是RabbitMQ延迟队列,但这对基础架构组件的要求比较高,健身房预约系统引入MQ属于过度设计。我不建议在新手项目里用MQ做这个事——你最终要的是让用户在15分钟内完成支付,精确到秒没有意义。
定时取消的代码思路:
@Scheduled(cron = "0 * * * * ?") // 每分钟执行一次 @Transactional public void cancelExpiredOrders() { // 1. 查出所有待支付且超时的订单 List<BookingOrder> expiredOrders = bookingOrderMapper.selectExpiredOrders(15); for (BookingOrder order : expiredOrders) { // 2. 取消订单 order.setStatus(3); bookingOrderMapper.updateById(order); // 3. 释放名额 scheduleMapper.increaseRemaining(order.getScheduleId()); } }这里又用到了“更新订单状态 + 释放名额”这两个动作,仍然在同一个事务里,逻辑基本一致。你会发现数据库设计里那张class_schedule表的remaining字段,确实是并发预约的“主战场”,后面所有经验几乎都在围绕它做文章。
5. 前端小程序核心页面与Vue管理后台的实现
5.1 小程序端:五个核心页面的业务逻辑
小程序端不算登录和支付中间页,真正的核心页面主要这几张:
| 页面 | 功能 |
|---|---|
pages/index/index | 首页金刚区入口 + 公告栏 + 当日热门课程轮播 |
pages/course/list | 按日期和课程类型筛选排课,展示剩余名额 |
pages/course/detail | 课程详情、教练信息、剩余名额、立即预约按钮 |
pages/order/confirm | 预约确认页,展示金额、选择支付方式 |
pages/order/list | 我的预约列表,区分待支付/已确认/已完成/已取消 |
这里我不逐个贴所有页面代码,只挑两个最值得讲解的。
课程列表页是预约转化率的关键。用户来不是看课程介绍的,是来看“哪节课现在还能约”。所以列表页的每个课程卡片什么信息最重要?date(星期几)、start_time(上课时间)、remaining(剩余名额)。真正做过小程序的人都知道,页面越聚焦,用户决策成本越低。这里要强调一个交互细节:当remaining <= 0时,按钮要置灰显示“已约满”,同时不能让用户点击后才知道没名额,对用户体感的伤害太大了。
预约确认页展示的金额不能直接从后端schedule.price读就完事,要展示明细:课程费、可用余额抵扣、实际支付金额。哪怕第一版没有余额抵扣功能,也要预留这个计算位置(显示“余额支付”与否的开关),后续运营提需求的时候,改动范围会小很多。
5.2 小程序端请求封装与登录态刷新
小程序端的request请求不能直接裸用wx.request,建议封装一个request.js工具。核心逻辑如下:
// utils/request.js const BASE_URL = 'https://api.example.com'; // 改成你的后端域名 function request(options) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data); } else if (res.statusCode === 401) { // token过期:重新登录 wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); reject(new Error('登录已过期')); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(new Error(res.data.msg)); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };为什么单独封装这一层?因为一旦后端接口变更(比如需要加签名字段、需要换token刷新逻辑),你只需要改这一个文件,不需要在几十个页面里分别修改调用代码。封装的价值在接口规范变动的场景下会体现得非常明显。
5.3 Vue管理后台:Element UI排课管理页
管理后台是运营人员的“工作台”,核心页面除了登录页,最主要的是排课管理和预约订单管理。
排课管理页面我用Element UI的el-date-picker做日期选择,el-table展示当天所有课程和教练的排班,用el-dialog弹窗进行新增排班。每次新增排课,前端把courseId、coachId、startTime、duration、capacity这些参数拼好后,POST到后端接口。后端保存前先查一次“这个教练这个时间段有没有已存在的排课”,有就直接报错“该教练此时间段已有课程”,这条判断逻辑对运营每天排课非常关键,避免人手滑排重。
订单管理页面比较简单:一个表格展示orderNo、会员昵称、课程名、教练名、预约时间、金额、状态,状态列用el-tag渲染不同颜色,再配一个筛选条件即可。真正的高频操作是“核销”按钮,运营扫一眼确认用户到店后在订单行点“核销”,后端把订单状态从1改成2,并把member_card的used_times加1(如果是次卡场景)。
这里有一个产品层面容易忽略的细节:核销动作要有二次确认弹窗。你在管理后台点错一个核销,用户的次卡被扣了,要去数据库手动改,运营会直接骂人。el-popconfirm包一层就够了。
6. 支付环节的坑与解决方案
6.1 微信支付流程在小程序端的正确姿势
小程序端发起支付的流程大家应该都见过:前端调wx.requestPayment,后端需要先调用微信支付统一下单接口获取paySign等参数。这里最容易被新手搞乱的是签名算法。
正确流程是:
- 前端拿到订单号后调后端
/api/pay/unifiedOrder接口。 - 后端拿着
out_trade_no、total_fee、openid、notify_url这些参数去请求微信支付统一下单API。 - 微信返回
prepay_id后,后端生成paySign并返回给前端。 - 前端拿着
paySign等参数调wx.requestPayment唤起支付面板。
注意:绝对不能在后端直接返回prepay_id就让前端去支付,paySign必须由后端生成。paySign的算法是把appId、timeStamp、nonceStr、package=prepay_id=xxx进行字典序排序后拼接,用商户API密钥做HMAC-SHA256签名。
简化后的后端核心代码:
public PayParamsVO unifiedOrder(Long orderId, Long memberId) { // 1. 查订单,校验属于当前会员且状态为待支付 BookingOrder order = bookingOrderMapper.selectById(orderId); if (order == null || !order.getMemberId().equals(memberId) || order.getStatus() != 0) { throw new BizException("订单状态异常"); } // 2. 调微信统一下单接口获取 prepay_id String prepayId = wxPayService.unifiedOrder( order.getOrderNo(), order.getAmount(), member.getOpenid()); // 3. 生成前端支付参数 String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = UUID.randomUUID().toString().replace("-", ""); // 4. 重新用 prepay_id 生成 package 和 paySign String packageStr = "prepay_id=" + prepayId; String paySign = wxPayService.generatePaySign(timeStamp, nonceStr, packageStr); return new PayParamsVO(timeStamp, nonceStr, packageStr, paySign); }6.2 支付回调:订单状态的最终确认
支付结果不能以“前端收到支付成功回调”为准,必须以微信服务器的异步通知为准。因为前端回调可能被伪造,也可能因为用户退出小程序而丢失。后端需要提供一个/api/pay/notify接口,接收微信的回调请求,用微信支付平台证书验证签名,然后更新订单状态。
回调接口有一个请求幂等性问题:微信同一个支付结果可能会通知多次,你的处理逻辑必须保证“重复通知不会重复处理”。最简单的方式是查询订单当前状态——如果已经是1(已确认),就直接返回success,不做重复操作。
回调接口的简化逻辑:
@PostMapping("/api/pay/notify") public String payNotify(@RequestBody String xmlData) { // 1. 验证签名(用微信支付证书) if (!wxPayService.verifyNotify(xmlData)) { return "fail"; } // 2. 解析出订单号和支付结果 String orderNo = wxPayService.parseOrderNo(xmlData); boolean success = wxPayService.isSuccess(xmlData); // 3. 找到订单,幂等处理 BookingOrder order = bookingOrderMapper.selectByOrderNo(orderNo); if (order != null && order.getStatus() == 0 && success) { // 更新订单状态、记录支付流水 order.setStatus(1); bookingOrderMapper.updateById(order); // 记一条支付流水 paymentLogMapper.insert(...); } // 4. 返回给微信:收到通知,请勿重复发送 return "success"; }支付这一块特别强调一个细节:回调接口一定不要加统一鉴权拦截器。很多新手的全局拦截器会把所有/api/**接口都要验证Token,结果微信服务器来访问时没有Token,接口直接返回401,微信就一直重试,订单永远是待支付。正确做法是:对/api/pay/notify这个地址做白名单放行,只校验签名。
7. 常见问题与排查实录
7.1 并发预约导致超卖?先看日志里有没有“已约满”
有一次系统上线后运营反馈“下单量超过课时人数了”,我第一反应不是去看代码,而是去查后端日志。果不其然,日志里同时出现了好几条课程名额已满,请选择其他时段的报错——这说明防超卖逻辑已经在工作了,只是前端没感知到这个错误提示,用户疯狂点“提交”按钮造成了多次请求。
排查结论:这不完全是代码bug,更可能是前端没有做按钮防抖处理。用户点了一次没反应,又点了一次,后端事务还没提交,第二次请求进来了,名额又扣了一次。修复方式:前端在点击提交后,按钮进入disabled状态,3秒内不可点击;后端再兜底一个“同一会员同一排课5秒内不能重复发起预约”的幂等校验。
7.2 小程序打开页面报“token过期”,但明明刚登录过
这个问题的根源几乎都是JWT的有效期设得太短,或者小程序端在收到401后没有做静默刷新直接跳登录。在实际场景中,用户可能在小程序里待半小时不操作,Token过期当然正常。合理方案是Token有效期设为7天(体验型App可以更长),并且在小程序端网络请求里统一捕获401状态——如果是登录过期,弹提示并去登录;如果只是做个临时校验失败,则需要后端能区分“Token无效”和“Token过期”,降级为用户重新调用登录。
另一个经验是:不要把session_key存在本地并用于解密用户手机号之后就不管了。session_key的有效性和微信服务器同步,一旦失效,前端提交phone解密就失败。这通常发生在用户上次授权登录之后隔了好几天才再次进入页面。建议手机号授权获取phone_code后,直接传给后端,由后端通过phone_code调用微信接口换取手机号,而不是用缓存里的session_key自己解密,这样时效性更好。
7.3 数据库导入了,但项目连不上库?八成是这里
很多新手拿到源码后,数据库文件导入了,Spring Boot启动也显示成功,但一查接口就报Table 'ginas' doesn't exist。原因基本是application.yml或application.properties里的数据库名和实际建的库名不一致。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ginas_app?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver这里有个特别容易忽略的点:serverTimezone必须设置成Asia/Shanghai,否则本地时间和数据库时间差8小时,预约时间会出现“提前8小时开始”的诡异问题,排查起来非常头疼。另外MySQL 8.0的驱动类名是com.mysql.cj.jdbc.Driver,用MySQL 5.x的com.mysql.jdbc.Driver会报警告。
7.4 管理后台跨域问题:Cookie还是Token?
前后端分离项目,管理后台在localhost:8081,后端在localhost:8080,浏览器默认是不允许跨域调用的。最常见的解决方式是后端配置跨域过滤器。这里建议用CorsFilter允许指定源(不要用*,因为要允许携带Cookie的话*不安全),或者直接用@CrossOrigin注解加在Controller上。
注意一个关键细节:如果你在管理后台登录时用的是session保持登录态,那必须开启allowCredentials(true)并指定具体的allowedOrigins而非*;如果你用的是JWT Token,那就不存在cookie跨域问题,把Token放在Authorization头里即可。这里建议新项目直接上JWT,能省掉session共享、负载均衡等一堆后续问题。
7.5 其他高频问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 小程序真机上图片不显示 | 图片域名没配到小程序后台downloadFile合法域名 | 登录微信公众平台 → 开发 → 开发管理 → 服务器域名 |
| 订单状态一直是“待支付”但钱扣了 | 回调接口没写对或被拦截器拦截了 | 打开ngrok或内网穿透看下有没有微信请求到达后端;检查回调URL是否可公网访问 |
| 预约成功后列表页不刷新 | 小程序页面生命周期问题,用了onLoad缓存了数据 | 改成onShow每次显示时重新拉取列表 |
| 库存异常扣减 | 排课有并发下单 | 使用remaining > 0条件更新并检查affected rows |
服务启动报Data too long | 字段长度定义过短 | 调整数据库表字段长度(比如nickname VARCHAR(64)) |
8. 一点实操心得:从零开始怎么把这个系统跑通
最后分享几条实际开发中非常有体感的经验。
第一,先跑通最小闭环,再完善功能。不要一上来就想把会员卡、次卡、优惠券、积分全做完。先搞定“用户登录 → 查看课程 → 提交预约 → 支付成功 → 管理后台核销”这一条最核心的链路,然后逐步加功能。这条主链路通了,系统已经具备上线条件了,后面加的都是增量。
第二,接口文档一定要在开发前写好。哪怕自己一个人开发,也要把接口定义、参数含义、返回结构写出来放到项目的docs目录下。因为后期小程序端和后端是分开开发的,没有文档,联调的时候全靠口口相传,效率极低。我自己常用的方式是维护一个API.md,后端每完成一个接口就更新一次,小程序端照着文档对接即可。
第三,数据库设计和表字段的命名统一用下划线。member_id、class_schedule_id、create_time这样的命名,在Java实体类里用MyBatis Plus的驼峰自动映射memberId、createTime,很自然地就能对应上。如果数据库字段叫memberID或者MemberId这种不规则写法,会给ORM映射增加不必要的麻烦。
第四,数据备份和SQL脚本管理从第一天就开始做。sql/init.sql只放表结构和基础数据,不要每次改表都手动在数据库里操作。推荐使用flyway这类工具管理数据库版本,或者至少做到:每次表结构变更,就把对应的ALTER TABLE语句存到一个sql/update_v1.1.sql文件里。后面项目的复现和排产全依赖这套脚本。
小程序开发调试时,wx.request请求的域名必须是HTTPS且已经备案,本地开发时的处理办法是:在微信开发者工具右上角“详情”里勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”,这样才能用http://localhost:8080进行本地联调。这个选项比较隐蔽,经常有新手卡在这一步半天不知道什么原因。
还有一个小细节:不要把appid和secret写在前端代码里。小程序端的appid会明文存在代码包里,而secret只应该存在于后端配置里,前端只需要把code传给后端换取openid即可。这也是为什么登录接口一定要走后端的原因,无论是appsecret、支付证书还是数据库密码,都是后端专属机密,前端一概不碰。
9. 这个项目还能怎么扩展
第一,接一个课程评价体系。健身房很需要口碑数据,用户上完课可以给教练打分、写评价,教练端或者管理后台能看到平均分和评价列表。这个功能只涉及两张表(评价表、评分汇总字段),对现有系统侵入极小。
第二,增加会员储值卡和优惠券模块。目前系统只做了预约逻辑,如果把储值、优惠券、积分加进来,配合支付回调一起处理,就是一个比较完整的商业闭环了。数据库结构也可以提前预留member_wallet(钱包)和coupon(优惠券)两张表。
第三,引入数据统计看板。管理后台加一个首页统计面板:今日预约人数、本周营收、课程约满率Top10、教练排班利用率。这些数据可以直接从booking_order和class_schedule表里用GROUP BY聚合查出来,再用Vue的图表库(如echarts)展示,运营价值非常高。这也是一般“毕设”项目与商业项目拉开差距的地方。
健身房预约系统的核心,说到底是对“教练时间”和“课程名额”两类资源的在线管理。数据模型建好了,交易状态机理清了,剩下的功能基本都是在既有框架上做增量。把上面说的角色权限、事务处理、并发控制这些基本功练扎实,这个项目你能带走的绝对不只一套源码。