
年初那会儿一个准备毕业的学弟找到我说毕设想做一个“健身房预约小程序”问我把后端、管理端、小程序端三大块串起来需要多长时间。当时我手上正好有一套Spring Boot Vue的管理端工程和一个原生微信小程序的用户端整体思路是从一套通用的“预约类系统”里抽出来的于是我把健身房的业务往里一装前排皮、课程、预约、核销这一套流程走通前后花了两周。今天把整个过程完整复盘一遍从需求拆解、数据库设计、后端接口到Vue管理端和小程序端的实现再到最终源码、数据库脚本和文档怎么落地使用全部讲清楚正好给做同类课题、或者第一次碰这种“三端联调”项目的朋友当参考。这套系统说白了就干三件事会员在小程序上按课表选课预约教练和前台在Vue管理端维护课程、处理核销管理员看数据做决策。后端统一提供接口MySQL负责存所有业务数据整个工程按“小程序端 管理端 后端服务 数据库脚本”四部分交付源码、SQL初始化脚本、开发文档和部署说明都全。下面按照我当时从零搭建的逻辑顺序讲。1. 这套健身预约系统到底解决什么问题1.1 传统健身房预约的现场混乱我去过不少传统健身房排课基本靠前台手写表格加微信群接龙结果就是教练临时调课没人知道会员约好的课到现场发现满员还有人约了课不来也不取消导致座位一直空置。真实场景里这些矛盾每天都在重复。所以最初的需求分析阶段我并没有一上来写代码而是先把线下流程走了一遍把“谁在什么时间对什么资源做了什么操作”画成一张表。这套系统的核心业务对象其实只有三个会员、教练、课程。会员在小程序端浏览课程并提交预约教练在后台维护自己的课程计划管理员负责审核、排期和统计。所有的业务动作比如预约成功、课程取消、核销完成都需要有一个明确的记录和状态流转否则数据库里存的就只是一堆孤立的表单数据没有任何业务意义。1.2 角色与功能边界我把系统按角色拆成了三个端每类角色只干自己那一摊事角色使用端核心功能会员微信小程序注册登录、浏览课程、按时间/教练筛选、在线预约、取消预约、查看个人预约记录教练微信小程序/管理端查看自己的排课表、设置课程计划、查看预约学员名单、标记课程状态管理员Vue管理端会员管理、教练管理、课程管理、排期管理、预约核销、数据统计报表功能边界定义清楚之后最明显的一个变化是前端界面不再是“功能列表的堆积”而是每个角色只面对自己的核心事务。比如小程序端首页就是课程卡片流点击卡片进入详情页详情页里只放了“预约”这一个主按钮取消入口放在“我的预约”里避免用户误操作。这套打法的好处后面在联调阶段体现得很明显——接口字段和页面信息一致几乎没有因为需求反复导致的前后端推倒重来。1.3 一个真实的预约链路示例我拿一个典型场景说明整个系统的运转方式。周六下午4点有一节动感单车课满员人数是20人会员小张在小程序里看到课程卡片点进详情看到剩余名额还剩5个于是点了预约按钮。后端收到请求后先校验小张是否登录、课程是否存在、当天是否已经约过同一时段的其他课、剩余名额是否大于0。全部通过后预约记录状态置为“已预约”课程表的剩余名额减1。到了开课时间教练在小程序或管理端点“开始上课”管理员核销后预约状态变为“已完成”。如果小张临时有事在开课前2小时可以取消剩余名额加1。整个过程就是一套名额的加减和状态的流转搞清楚这个链路后端的接口设计就顺理成章了。2. 技术选型与工程结构为什么是Spring Boot Vue 原生小程序2.1 后端用Spring Boot搭配MyBatis Plus的理由这套系统的后端我选择Spring Boot 2.x MyBatis Plus没有上Spring Cloud那套微服务。原因很直接预约系统是一个典型的单体应用学校作业、课程设计、小型商业项目都适合这种形态。Spring Boot可以把项目最快的跑起来内嵌Tomcat一个jar包就部署完事。MyBatis Plus解决了日常开发里一大半的重复工作单表CRUD不用自己写SQL分页查询直接selectPage逻辑删除、自动填充时间戳这些开箱即用。以会员多条件查询为例MyBatis Plus的条件构造器写起来非常直观LambdaQueryWrapperMember wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getKeyword()), Member::getName, query.getKeyword()) .eq(query.getStatus() ! null, Member::getStatus, query.getStatus()) .orderByDesc(Member::getCreateTime); IPageMember page memberMapper.selectPage(new Page(query.getPageNum(), query.getPageSize()), wrapper);一句话总结MyBatis Plus负责把“业务和SQL的粘合层”尽可能简化我只需要在Service层专注处理预约状态、名额扣减这些真正的业务逻辑。2.2 Vue做管理端小程序做用户端各司其职刚开始学弟问过我一个问题为什么不在小程序里直接做一个管理后台省得维护两个前端这个想法看起来省事实际用起来很别扭。微信小程序适合轻量、频繁、移动场景的操作但管理员需要看着大屏幕处理数据、核对排期、看统计图表这些工作在电脑浏览器上做才舒服。Vue 2 Element UI这套组合在后台管理领域非常成熟表格、表单、弹窗、日期选择器这些组件齐全开发效率极高。管理端和小程序的职责切分如下Vue管理端岗位是“后台运营”重点是表格、筛选、编辑、批量操作、图表统计。我用了Vue Router做页面跳转Vuex管理管理员登录状态和权限信息Axios统一封装了请求拦截器每次请求自动带上前端存储的token。小程序端岗位是“C端用户”重点是流畅浏览、快速预约、消息触达。用的微信原生语言没用uni-app因为预约类小程序不涉及太多跨端需求原生在性能和控制力上更稳后面说到小程序登录、手机号授权这些能力都直接调微信官方接口更省事。2.3 工程目录长什么样整个交付工程分成四块源码头一次看不会迷路gym-reservation-system/ ├── server/ # Spring Boot后端工程 │ ├── src/main/java/ # controller/service/mapper/entity/config │ ├── src/main/resources/ # application.yml、mapper xml等 │ └── pom.xml ├── admin-web/ # Vue管理端工程 │ ├── src/views/ # 会员管理、课程管理、排期管理、统计等页面 │ ├── src/api/ # 按模块封装的axios请求 │ └── package.json ├── miniapp/ # 微信小程序原生工程 │ ├── pages/ # 首页、课程详情、预约记录、我的等页面 │ ├── utils/ # 请求封装、日期处理、公共方法 │ └── app.js # 全局启动逻辑 └── database/ ├── gym_db.sql # 建库建表初始数据脚本 └── README.md # 数据库初始化说明目录拆得清楚后面打包部署、补文档的时候能省非常多的时间而且源码交付给别人时对方也能第一时间定位到对应的代码位置。3. 数据库设计预约系统的表结构和状态机才是核心3.1 核心表结构设计数据库我用的MySQL 8.0字符集统一utf8mb4——因为业务里会有中文和生僻字utf8mb4才能在排序、查询时不出问题。整体设计围绕“预约”这个动作展开共9张核心业务表分别是会员表、教练表、课程分类表、课程信息表、课程排期表、预约订单表、核销记录表、管理员表和配置表。表名核心字段用途说明memberid, openid, name, phone, status, create_timeC端用户基础信息openid用于小程序登录关联coachid, name, avatar, specialty, intro, status教练基础信息和擅长领域categoryid, name, sort课程分类比如动感单车、瑜伽、力量训练courseid, category_id, name, cover, intro, duration, max_count课程基础信息不区分具体时间相当于“课程模板”course_scheduleid, course_id, coach_id, start_time, end_time, remaining_count, status一次具体的课例如“周六16:00单车课”预约扣减的就是remaining_countreservationid, member_id, schedule_id, status, reserve_time, cancel_time用户与具体课程的预约关系一次预约一条记录check_recordid, reservation_id, operator_id, check_type, create_time核销记录开课签到或管理员标记完成admin_userid, username, password, real_name, role, last_login_time管理端登录账号这里最容易犯的错是把课程和排期混在一张表里。如果直接设计一张course表每行既存课程信息又存上课时间后面教练排期、剩余名额管理会非常痛苦。课程是“静态模板”排期是“动态实例”一个课程可以被安排到多个时间段每个时间段有独立的剩余名额这两者必须拆开。3.2 预约状态机几张表之间怎么流转预约订单的状态我用一个status字段表示约定如下0待确认小程序端提交预约后进入等待管理员审核适合私教课等需要人工审核的场景1已预约审核通过或团课直接预约成功有效占用名额2已完成开课后核销或者会员签到达成完成3已取消会员在小程序自行取消或管理员后台代取消4已爽约预约成功但未在开课前取消也未到店核销系统自动标记我遇到过不少预约系统把状态做得特别细什么“已支付”“待退款”“已退课”“已过期”全都堆在一起结果前端页面状态流转分支多得写不动。实际做下来团课预约的核心场景根本不需要这么复杂5个状态足够覆盖。如果是付费预约顶多再加一个“待支付”状态不要一上来就把状态机设计得过于庞大。状态流转用一个小表记录操作日志更好排查问题比如用户取消预约前后一定建议同时写一条操作记录否则后面人数对不上时连谁什么时候取消的都不知道。3.3 并发预约的名额扣减处理这是预约系统最需要认真想清楚的一个细节。20个名额的课50个人同时点预约如果代码先select剩余名额再update扣减必然超卖。我的做法是直接对course_schedule表执行原子更新UPDATE course_schedule SET remaining_count remaining_count - 1 WHERE id #{scheduleId} AND remaining_count 0 AND status 1;如果更新的影响行数为1说明名额扣减成功然后再新增预约订单如果影响行数为0直接返回“名额已满”。这比分布式锁、悲观锁都轻量而且对单体应用完全够用在数据库层面就保证了名额不会扣成负数。4. 后端接口预约主链路怎么实现4.1 接口清单与分层后端按Controller → Service → Mapper三层组织总计23个接口按模块划分如下模块接口示例说明用户端POST /api/member/login、GET /api/member/profile小程序登录、个人资料课程端GET /api/course/list、GET /api/course/detail/{id}课程列表与详情排期端GET /api/schedule/list、GET /api/schedule/available按日期/课程查可预约排期预约端POST /api/reservation/create、GET /api/reservation/my、POST /api/reservation/cancel预约、我的预约、取消预约管理端POST /api/admin/login、POST /api/admin/course/save、POST /api/admin/schedule/save、GET /api/admin/reservation/page管理端的登录与各类管理接口核销端POST /api/admin/reservation/checkin开课核销确认Controller层只做参数接收、调用Service、统一返回结果所有业务判断放到Service层错误信息用自定义异常抛出然后由全局异常处理器统一捕获。这里我特意封装了一个ResultT响应体包含code、message、data三个字段前后端约定好状态码小程序和管理端在处理网络请求时只要判断code 200即可。4.2 预约主流程的Service实现预约创建接口是整个系统最核心的一段代码逻辑按顺序清理基本不绕弯Transactional(rollbackFor Exception.class) public Long createReservation(Long memberId, Long scheduleId) { CourseSchedule schedule scheduleMapper.selectForUpdate(scheduleId); if (schedule null) { throw new BizException(课程排期不存在); } if (schedule.getStatus() ! 1) { throw new BizException(该课程已不可预约); } if (schedule.getRemainingCount() 0) { throw new BizException(名额已满); } // 校验同一时段是否已预约其他课程 int repeat reservationMapper.countByMemberAndTimeSpan( memberId, schedule.getStartTime(), schedule.getEndTime()); if (repeat 0) { throw new BizException(同一时段已预约其他课程); } // 原子扣减名额 int updated scheduleMapper.deductCount(scheduleId); if (updated 0) { throw new BizException(预约失败名额不足); } Reservation reservation new Reservation(); reservation.setMemberId(memberId); reservation.setScheduleId(scheduleId); reservation.setStatus(1); reservation.setReserveTime(LocalDateTime.now()); reservationMapper.insert(reservation); return reservation.getId(); }需要说明的是selectForUpdate这一步并不是必须的。如果只用deductCount的原子更新并发下也可以保证不超卖。但加上行级锁之后可以在同一个事务内读到准确的剩余名额用来做更复杂的业务判断。比如后面的“同一时段是否冲突”校验如果完全靠数据库约束会比较麻烦锁住排期行后再做校验反而简单可靠。注意Transactional一定要加上因为“扣名额生成预约记录”必须是一个原子操作否则扣了名额但记录没生成数据就出大问题了。4.3 防重复提交与接口安全性预约属于“高频有副作用”的接口一个用户手抖连点两下预约按钮可能就产生两条预约记录。我做了两层防护第一层是小程序端按钮点击后立即置为disabled并显示“处理中”单纯从前端挡住大部分重复点击第二层在后端给同一用户对同一排期加了唯一约束reservation表里(member_id, schedule_id, status)加唯一索引并且设置状态只在有效范围内。这样即使前端拦截失败数据库也能兜底。管理端的接口我另外做了简单的JWT校验。管理员登录成功后后端签发一个有效期为2小时的token管理端每次请求在Authorization头里带上后端用一个拦截器统一校验。小程序端的用户登录用的是微信sns/jscode2session换来的openid后端通过openid定位用户不需要自己再做一套账号密码体系。5. Vue管理端排课、核销和运营看板5.1 登录鉴权与路由守卫管理端页面我接手时定的结构是登录页、首页数据看板、会员管理、教练管理、课程管理、排期管理、预约订单、核销管理、系统设置。在main.js里统一注册了Element UI路由用Vue Router的懒加载方式按模块拆分包。登录时把后端返回的token存到localStorage同时存一份管理员信息到Vuex里。路由守卫是管理端必不可少的一环未登录的用户不能进入任何一个后台页面直接重定向到登录页router.beforeEach((to, from, next) { const token localStorage.getItem(admin_token); if (to.path ! /login !token) { next(/login); } else { next(); } });这个逻辑看着简单但很多初学Vue的同学会把每个页面都写上“如果没token就跳转”导致同一个逻辑复制了十几份后续改起来全是漏。Vue Router的全局前置守卫一处写死所有页面自动生效。5.2 课程排期管理端最复杂的操作界面排期管理是这个系统管理端最复杂、也最有价值的一块。界面左侧是课程分类树点击某个分类后右侧显示该分类下的课程列表选择任意一门课程后弹窗填写上课时间、选择教练、填满员人数点保存就生成一条course_schedule记录。这里有一个重要的校验逻辑排期时不允许教练在同一时间出现在两间教室。所以新增排期接口里我查了一下该coach_id在当前时间段是否有其他有效排期有就直接抛异常。另外排期状态有三种草稿、发布中、已结束。管理员排好的课默认状态是“发布中”小程序端才能看到不想开放的课可以先存草稿改好再发布。前端表格里用了el-date-picker的datetimerange组件一次性选择开始和结束时间提交给后端时拆成startTime和endTime两个字段。刚调接口时容易忽略时区问题前端传的时间如果直接JSON.stringify会带上T和Z后端解析LocalDateTime时经常报错最后我在前端把时间格式化成了yyyy-MM-dd HH:mm:ss这种标准字符串后端用Jackson的JsonFormat统一解析问题彻底解决。5.3 预约核销与数据看板核销是运营环节的最后一步。管理员在预约订单页面根据会员手机号或预约号搜索记录找到核销按钮点击后调POST /api/admin/reservation/checkin后端把预约状态从“已预约”改成“已完成”写一条核销记录。有人会问为什么不定时自动完成实际业务里用户可能提前到、也可能迟到管理员人工确认比系统自动判定更符合健身房的实际管理场景。数据看板这块我用ECharts做了三张图近7日新增预约趋势折线图、课程预约热度排行柱状图、会员到店率占比环形图。管理员登进去第一眼就能知道自己场地哪节课卖得最好、哪节课经常放鸽子这是整个系统“除了增删改查之外最加分”的功能。为了方便前端画图后端专门做了一个GET /api/admin/statistics/overview接口一次性返回所有图表需要的数据前端只需要把数据灌到图表配置里。6. 小程序端授权登录、约课和业务消息6.1 微信登录与获取手机号小程序的登录流程我遇到的坑比代码本身多。以前用的wx.login加普通表单填手机号的方式还能用现在为了产品合规和用户体验更好的做法是让用户点击登录按钮触发wx.getUserProfile获得公开信息再通过button open-typegetPhoneNumber拿到微信绑定的手机号。后端用code换openid和session_keygetPhoneNumber返回的encryptedData需要后端解密才能拿到真实手机号。一个关键注意点小程序的getPhoneNumber能力只有企业主体的小程序才能真正返回有效手机号个人主体的小程序调这个接口会直接失败。如果项目跑在个人主体下就要退回传统方式让用户手填手机号加验证码。这个限制在文档和交付说明里必须写清楚不然对方拿源码跑起来发现手机号一直是空的又找不到原因。6.2 首页课程流与详情预约小程序首页我用微信原生的scroll-view做纵向滚动列表每张课程卡片显示封面图、课程名、上课时间、教练、剩余名额。数据来源于课程列表接口分页用onReachBottom触底加载。页面的体验细节在于剩余名额的醒目度名额低于5个时卡片右侧显示红色“余3”标签名额为0时显示“已满”并且预约按钮置灰。这个判断不需要后端单独返回字段前端用remainingCount自己做渲染控制就行。课程详情页的预约按钮是与后端交互的主入口。用户点预约时小程序先检查是否有openid没有的话引导登录然后调用预约创建接口。在这里我把disabled和防重复进一步做严了——用户点完预约后按钮文案变成“预约处理中”接口返回code 200后立即跳转到“我的预约”。如果接口返回错误比如“该时段已预约其他课程”要把后端返回的message原样弹出而不是只给一个通用的“预约失败”这样用户才知道自己到底哪一步有问题。6.3 会员中心与预约记录“我的”页面包含用户头像、昵称、手机号、我的预约、我的收藏、联系客服、关于系统等入口。我的预约记录分三个tab待上课、已完成、已取消。待上课列表里每条记录右侧显示“取消预约”按钮点击后弹出确认模态框走后端取消接口。这里后端也要校验超过开课前2小时的预约不允许取消。小程序端通过判断剩余时间来决定按钮是否展示前后端双保险。消息通知我用了微信订阅消息的一次性订阅能力。用户预约成功后小程序调wx.requestSubscribeMessage让用户选择是否接收“预约成功通知”和“开课提醒”用户同意后后端在开课前30分钟通过订阅消息推送提醒。这个功能上线前需要在小程序后台申请消息模板审核周期一般1-3天所以打包之前就要把模板ID申请好否则后面要重新发版。7. 源码、数据库与文档的落地使用7.1 数据库脚本怎么初始化交付包里database/gym_db.sql包含了建库、建表、初始账号和演示数据。拿到脚本后第一步是MySQL里创建一个空的gym_db数据库然后导入脚本。需要注意MySQL 8和MySQL 5.7在字符集、排序规则上有细微差别脚本第一行最好声明SET NAMES utf8mb4避免导入的时候中文乱码。如果用户本机是Windows直接在Navicat里右键运行SQL文件最省事Linux环境下用mysql -u root -p gym_db gym_db.sql导入即可。application.yml里的数据库连接信息是占位符部署时必须改三个值jdbc:mysql://localhost:3306/gym_db、数据库用户名和密码。很多同学第一次跑后端报错90%是这一步没改或者改了没重启。7.2 Vue打包放进SpringBoot管理端开发完成后的部署方式我推荐把Vue构建后的静态文件直接放进Spring Boot的src/main/resources/static目录然后打成同一个jar包运行管理端和后端只占用一个服务端口部署成本最低。具体做法是在Vue工程根目录配置vue.config.js里的publicPath: ./否则打包后资源路径是绝对路径/js/app.js放进后端后直接404。然后运行npm run build把dist目录下所有文件复制到后端的static目录重新打包后端。启动后浏览器访问http://服务器IP:8080/index.html就是管理端/api/**接口自动落到Spring Boot的Controller上不涉及跨域问题。小程序端不用打包进后端微信开发者工具里选择项目目录修改app.js里的baseUrl为后端服务器的公网地址然后点“上传”把代码提交到微信后台在后台提交审核发布即可。7.3 交付文档里必须写清楚的几件事这套系统的文档最终交付了四份需求说明书、数据库设计文档、接口文档、部署说明书。我的经验是接口文档不要手写直接用Swagger自动生成后端代码里的注解写好描述比手动维护Word文档靠谱一万倍。数据库设计文档里除了ER图和表结构一定要附上字段枚举含义说明表特别是reservation.status那5个状态值否则下一个人接手代码时完全看不懂数字代表什么。部署文档只需要讲环境要求、初始化SQL、改配置、打包、访问地址这五步重点标注“小程序端需要企业主体”这一项就够了不要写一堆废话。8. 实测运行中的坑与后续扩展8.1 实际开发中踩过的几个坑第一个坑是时间戳时区。后端服务器时区是UTC前端的本地时间却是UTC8表现在业务上就是课程时间比实际上课时间快了8小时。后来我在application.yml里固定配置spring.jackson.time-zone: GMT8数据库连接串加serverTimezoneAsia/Shanghai前端传参统一成字符串格式才彻底压住这个问题。第二个坑是图片上传。课程封面图如果走后端自己存文件部署环境一变路径就乱非常麻烦。我在实际工程里直接把课程封面、教练头像的图床地址存在数据库字段里图片统一放对象存储或者图床后端只管存Url字符串前端直接渲染省去了静态资源映射的麻烦。第三个坑是“预约已满”的提示生效太慢。刚开始没有做任何缓存高峰期一个人预约要查三四张表慢倒是不慢但并发稍微上来一点数据库压力就明显。后来我把热门课程列表接口加了Redis缓存课程数量变化时通过管理端更新操作主动删缓存小程序端课程列表响应速度提升明显。缓存策略不复杂但一定要设置合理的过期时间并且更新课程后要触发缓存失效否则会出现数据不一致。第四个坑其实不是技术问题而是业务问题很多健身房希望会员爽约之后有惩罚机制比如一周内爽约两次就暂停预约资格。这个需求在小程序端和后端都要判断逻辑不复杂但容易在状态机设计时被忽略。我是把“爽约”作为一个独立状态维护的同时member表加了suspended_until字段记录暂停截止时间这样预约资格校验时检查一下当前时间是否在暂停期内就行。8.2 这套系统还能往哪些方向扩展预约系统的骨架搭稳之后往上加功能是很容易的事情。最直接的是做付费预约在预约创建时生成待支付订单接入微信支付统一下单接口支付成功后才真正扣减名额订单表加一个支付状态和支付流水号。其次是加会员卡体系不同卡种对应不同的课程预约权限、月预约次数限制这类业务本质上都是在reservation创建前加一个校验规则。更进一步可以把统计模块从简单的到店率扩展成经营报表按教练、按课程、按月度维度做盈利能力分析这些就是BI层的活了。我自己在实际项目中的体会是这类预约系统的技术难点不在某个单独的框架或页面而在于把“三个端 一个库”串起来之后所有数据流转和状态变更保持一致。只要数据库设计阶段把课程和排期拆清楚后端把名额扣减的原子操作守住小程序和管理端各自管好自己那一侧的用户体验整套系统就能稳定跑起来。健身房预约小程序这个方向本身的应用场景很广——除了健身房很多按时间预约的场所比如羽毛球馆、瑜伽室、私教工作室、甚至公司会议室预约底层的预约模型都长一个样。把这套工程吃透换一套业务前缀改一改表和字段就能很快复用到下一个场景里。