基于微信小程序的舒心按摩店管理系统:从毕业设计到能用的完整实操记录
每年毕业季都能看到大量“XX管理系统”题目,说实话,十个里面九个都是照着网上的模板换个皮。但如果你拿到的是“基于微信小程序的舒心按摩店管理系统”,这个题反而有点意思——它不像图书管理、学生管理那类纯CRUD,里面牵扯到预约时间冲突、会员余额扣减、技师排班、订单状态流转这些真实业务场景。把这款设计做好,你不仅能拿到不错的分数,还能在面试时讲出一套完整且经得起追问的业务逻辑。这篇文章我把整个项目从拆解需求、建表、写后端接口到小程序前端实现的全过程都记录一遍,附带踩过的坑和我认为最值得注意的细节,给正在为毕设头秃的同学一条可复制的路线。
先说下项目范围:小程序的用户端给顾客用,包含服务项目浏览、技师选择、在线预约、订单支付(或余额支付)、会员充值、预约记录、个人中心;后台管理端给店家使用,负责服务项目管理、技师管理、预约排班确认、订单查看、会员管理、充值记录统计。开发技术栈我选用的是SpringBoot + MyBatis + MySQL + 微信小程序原生,这个组合在毕业设计答辩时最常见,资料多、查错容易,而且每一层都能讲出知识点。
1. 项目整体设计:从“按摩店”的真实需求倒推系统模块
1.1 需求从哪来:不只是“做个系统”而已
按摩店和普通零售店有一个非常大的区别:服务时间是一个刚性的排他性资源。一个技师在某个时间段只能服务一个顾客,这和商品库存有本质不同——商品卖完了可以补货,但一个技师的下午两点到三点被你占了,其他客人就预约不上了。所以整个系统的核心不是“展示信息”,而是“维护时间资源”。
我最初和一位准备开店的朋友聊,他的按摩店是手工登记预约,本子上写着“张姐 14:00-15:00 李技师”,结果经常出现以下场景:电话预约时翻本子翻到五分钟前接的另一个单,两单撞在一起,客人到店后前台一边道歉一边协调;会员卡余额是Excel记录的,一次经脉疏通158元,手动减,偶尔忘了减;技师排班靠口头说,谁哪天休息完全凭自觉。听到这些痛点,你就明白这个系统的功能模块不是拍脑袋想出来的,而是每一个都能对应一个具体问题。
1.2 技术选型:为什么是微信小程序 + SpringBoot + MySQL
微信小程序作为用户端,理由很直接:顾客不需要下载App,打开微信扫码就能用,这个门槛低;按摩店本身没有能力运营独立App,小程序还能顺便解决一部分线上推广问题。技术上,原生小程序的WXML/WXSS语法学起来不快,但熟练之后页面渲染性能好,而且答辩时你可以直接展示微信开发者工具的调试页面,这是加分项。
后端选择SpringBoot,主要是看中它的自动配置和生态成熟。MyBatis作为持久层框架,SQL可控性强,适合像“查询某技师在某个时间段是否已有预约”这种需要手写条件和联表场景的语句。数据库用MySQL,稳健、支持事务,而事务对这个项目来说是命根子——会员充值和订单支付必须保证一致性。使用Redis做缓存我考虑过,但毕设为了降低部署复杂度,没引入,只在关键接口里做了MySQL层面的并发控制。
前端管理后台没有单独写Web页面,而是做了一个小程序管理员端角色。如果你是纯后端功底,这样做能少写一套Vue页面,还能把权限控制集中在小程序端角色切换里。当然如果导师要求必须有PC后台,可以用Vue+ElementUI再套一个,但本文以“小程序用户端 + 小程序管理员端 + SpringBoot后端”作为最终交付方案。
2. 数据库设计:五张核心表和一张关联表
2.1 用户、技师、服务项目、预约、订单表结构
建表是整个系统最不能省时间的环节。我见过太多人上来先写Controller,表结构随便搞,后面写SQL时才发现缺字段、逻辑绕,最终倒回来改表。我设计的表结构如下,每一行都有它的业务含义:
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 自增主键 |
| openid | varchar(64) | 微信唯一标识,天然唯一索引 |
| nickname | varchar(50) | 昵称 |
| phone | varchar(11) | 手机号,预约和联系用 |
| balance | decimal(10,2) | 会员余额,默认0 |
| created_time | datetime | 注册时间 |
这里重点说下openid,它是小程序的唯一用户标识。开篇就得明白一件事:我们不需要用户密码,密码登录在小程序里是无意义的。用户第一次进入小程序,前端调用wx.login获取code,然后后端用code换openid。所以user表里根本不需要password字段。
技师表(technician)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 自增主键 |
| name | varchar(20) | 技师姓名 |
| avatar | varchar(255) | 技师头像 |
| intro | varchar(255) | 简介、擅长项目 |
| status | tinyint | 1表示在职,0表示休息或离职 |
技师表不要设计得太复杂,很多毕设喜欢在这里塞“工龄”“等级”之类,但实际门店更关心的是“这个技师现在能不能被预约”,所以一个status就够了。
服务项目表(service_item)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 自增主键 |
| name | varchar(50) | 项目名称,如足底按摩 |
| duration_minutes | int | 单次时长(分钟),核心字段 |
| price | decimal(10,2) | 价格 |
| description | varchar(500) | 项目描述 |
| cover | varchar(255) | 封面图 |
duration_minutes是后续预约判断的关键:顾客选一个服务,系统要根据时长自动算出结束时间,从而判断时间槽是否冲突。
预约表(appointment)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 自增主键 |
| user_id | bigint | 预约顾客 |
| technician_id | bigint | 被预约技师 |
| service_id | bigint | 服务项目 |
| appointment_date | date | 预约日期,注意是日期不是时间戳 |
| start_time | datetime | 预约开始时间,精确到分钟 |
| end_time | datetime | 预约结束时间,由开始时间+服务时长自动计算 |
| status | tinyint | 0待确认 1已确认 2已完成 3已取消 4已爽约 |
| remark | varchar(255) | 客户备注 |
如果是纯手工计算时间槽,预约表缺少“技师+日期+时间段唯一索引”这种约束,容易出问题。我加了一个联合唯一索引uk_technician_time(technician_id, start_time),意思是“一个技师在一个具体的开始时间只能有一条预约记录”,这是最底层的防冲突保证,后面接口里还会再配合查询做更灵活的区间判断。
订单表(orders)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | 自增主键 |
| order_no | varchar(32) | 业务订单号,如MS加时间戳加随机数 |
| user_id | bigint | 下单用户 |
| appointment_id | bigint | 关联预约,一笔订单可对应一个预约 |
| amount | decimal(10,2) | 实付金额 |
| pay_type | tinyint | 1余额支付 2微信支付(毕设可只做余额支付) |
| status | tinyint | 0待支付 1已支付 2已退款 |
| created_time | datetime | 下单时间 |
订单和预约的关系:在顾客确认预约后立即生成预约记录,点击支付时再生成订单。这里有两种设计:预约先占位、随后支付;或者支付成功后再创建预约。考虑到“舒心”门店的场景,通常顾客会先看好时间再付款,所以我用的是“先预约占位、限时支付,超时未支付则释放预约”的策略。但毕设为了简化,可以做成:点击预约后直接生成一条状态为“待确认”的预约,同时创建一个状态为“待支付”的订单,用户选择余额支付时,余额足够则同时把预约状态改为“已确认”、订单状态改为“已支付”。如果余额不足就提示充值。
2.2 状态字段的艺术:订单状态和预约状态如何流转
状态字段是后端开发中最容易被忽视的业务逻辑,我强烈建议你画一张状态流转图再写代码。我这边的预约状态流转是:
- 待确认:用户刚下单,此时技师端还没处理,如果支付方式是余额且余额够,可以直接进入已确认,跳过待确认。
- 已确认:说明服务时间已经锁定,不可再被其他人预约。
- 已完成:到店服务结束后,由管理员端操作标记。
- 已取消:用户主动取消,这里要分时间节点,如果在服务开始前2小时以上取消且已支付,后台可以自动退款,但这个业务我在毕设里简化成允许无条件取消,已支付金额原路退回余额。
- 爽约:预约时间已过,用户未取消也未到店,标记为爽约,管理端手动设置。
订单的状态相对简单:待支付、已支付、已退款。需要注意的是订单和预约千万不要只在一个状态字段里玩出花,比如用订单状态去反推预约状态,这样会非常复杂。建议把两张表的状态解耦,业务层用事务同时更新它们。
3. 后端接口开发:预约冲突和会员余额扣减怎么处理
3.1 预约接口的核心逻辑:并发冲突避免
很多毕设问题都出在这个接口上:用户A和用户B同时选中了同一个技师同一天同一个时间段,两边都提交预约,结果数据库里出现了两条重叠记录。如果不处理,现场演示时就能翻车。
我的做法是三管齐下:
第一层是数据库唯一索引,即前面说的uk_technician_time(technician_id, start_time),这能挡住同一开始时间的完全重复。
第二层是业务查询时的区间重叠判断。因为两个预约可能开始时间不同但区间重叠,比如A预约14:00-15:00,B预约14:30-15:30,这样唯一索引也挡不住。所以需要在插入前执行SQL检测:
SELECT COUNT(*) FROM appointment WHERE technician_id = #{technicianId} AND appointment_date = #{date} AND status IN (0, 1) AND start_time < #{endTime} AND end_time > #{startTime}这条SQL的含义非常直观:数据库中已存在的任何预约,只要它的开始时间早于你想要的结束时间,并且它的结束时间晚于你想要的开始时间,就说明区间相交,这个时间段已经被占了。你可以把它理解成“两个人住同一间房,只要一个没走另一个进来了,就冲突了”,这段SQL是预约系统最重要的十条SQL之一。
第三层是在Service方法上使用@Transactional,并把预约记录的插入和订单记录的创建放在同一个事务里。因为一旦预约成功但订单创建失败,整个操作必须回滚,否则会出现“预约占了但没付钱订单”的脏数据。
我看到有些毕设用悲观锁,也就是在查询时使用SELECT ... FOR UPDATE,但需要事务包裹,复杂度高。我这里用的是先查再插的方式,虽然严格并行时可能有极小概率出问题,但配合唯一索引已经足够对付课程设计和演示场景。
3.2 会员余额扣减与订单支付的联动
按摩店最容易被吐槽的体验就是会员卡刷了半天余额对不上。所以系统里的余额操作必须走事务。用户点击“余额支付”时后端干的活是:
- 根据userId查出用户,用
select ... for update锁定该用户行(这一步很关键,防止用户同时支付两笔订单导致余额被扣成负数); - 判断
balance >= order.amount,不满足则直接抛出业务异常,返回“余额不足”提示; - 满足则执行
UPDATE user SET balance = balance - #{amount} WHERE id = #{userId}; - 把订单状态改为已支付,预约状态改为已确认;
- 插入一条余额流水记录(我专门建了
balance_log表,字段有user_id、change_amount、type(充值/消费/退款)、create_time)。
第3步为什么不用“先查出余额,在Java里减好,再update”?因为如果两个请求同时查到余额是100,同时认为可以支付一笔80的订单,然后分别执行update user set balance=80,实际应该剩20,结果却剩80,这就是经典的丢失更新。解决方式就是UPDATE ... SET balance = balance - #{amount}这样让数据库自己执行减法,而不是读出来算好再写回去。这个细节如果答辩时主动提出来,老师会觉得你是有并发意识的。
充值接口就是反方向:UPDATE user SET balance = balance + #{amount},同样要加事务和流水表。微信支付在毕设里可以只做一个“模拟支付”——点击支付后前端弹窗询问是否模拟支付成功,后端直接走流程。当然如果你有商户号,可以接微信支付相关接口,但那是另一个复杂度量级,纯毕设不推荐,除非你想给自己挖坑。
4. 小程序前端实现:从首页到“我的”页面
4.1 首页与服务列表:骨架屏和加载状态
小程序前端页面数量不多,我大致划分了四个主页面:首页(服务项目列表)、预约页(选技师、日期、时间槽)、订单/预约记录页、个人中心页。
首页的逻辑很简单:从后端拉取服务项目列表,每个项目卡片展示图片、名称、简介、价格、时长,点击进入详情或直接进入预约。这里说实话没有太多技术含量,但有一个体验细节值得注意——加载状态。按摩店的顾客大多是即时性需求,小程序网络请求如果卡住一两秒,用户就会以为点错了。所以我在首页的onLoad里请求项目列表时,用一个loading变量控制:页面初始化时渲染一个简单的骨架屏(用灰色块装饰),请求完成后填充真实数据。骨架屏就是几个静态的view加背景色模拟卡片形状,不用第三方组件。
网络请求我封装了一个request函数,统一处理baseURL、token、错误提示。调试阶段baseURL直接填本机局域网IP,比如http://192.168.1.101:8080,同时真机调试时要保证手机和电脑在同一WiFi下,并且在微信开发者工具里勾选“不校验合法域名”。这个问题我们后面细说。
4.2 预约页面的日期选择和技师时间槽
预约页是整个小程序交互最复杂的一页,核心流程是:用户先选服务项目,进入预约页,再选日期、选技师,最后选时间槽,点击预约下单。
日期选择我建议用picker的mode="date"原生组件,设置start为今天,end为七天后的日期。按摩店一般一周内预约就够,太长的日期也没有实际意义。用户选了日期之后,需要请求后端获取“该日期下每个技师的可预约时间段”。
这里有一个概念叫“时间槽”。按摩店的营业时间假设是10:00到22:00,服务时长60分钟,理论上可选的开始时间是每小时整点,也就是10:00、11:00……21:00共12个槽位。但实际不能把全部槽位放出来,因为有些槽位的技师已经被预约了。后端提供一个接口:根据日期和技师ID,返回该技师当天所有已预约的时间区间,前端拿到后把区间内的槽位置灰。我最初做了一个更“聪明”的方案:后端把所有可用槽位算好返回给前端,前端只需要渲染,这样逻辑集中在后端,前端省事。但实际使用下来发现,前端在用户切换服务项目、切换技师时反复请求接口,逻辑有点多,最后我改为后端返回该技师当天的预约列表,前端自己算时间段是否可用。两种方案都能用,就看你想把复杂度放在哪边。对于毕设,我推荐后端返回可用槽位的方案:前端直接拿到一个数组["10:00", "11:00", ...],渲染禁用状态,逻辑更直白。
时间槽的算法核心是:遍历当天的分钟序列,从营业开始时间起,每隔一个时间粒度(比如30分钟)检查当前开始时间加上服务时长是否超过营业结束时间,然后判断这个区间是否与已有预约冲突,不冲突则加入可用槽位。你直接在后端用一个循环和冲突判断写就可以。
List<String> startTimes = new ArrayList<>(); LocalTime openTime = LocalTime.of(10, 0); LocalTime closeTime = LocalTime.of(22, 0); int duration = service.getDurationMinutes(); // 按30分钟粒度生成 for (LocalTime time = openTime; time.plusMinutes(duration).isBefore(closeTime) || time.plusMinutes(duration).equals(closeTime); time = time.plusMinutes(30)) { LocalTime endTime = time.plusMinutes(duration); boolean conflict = appointmentList.stream().anyMatch(a -> a.getStartTime().toLocalTime().isBefore(endTime) && a.getEndTime().toLocalTime().isAfter(time)); if (!conflict) { startTimes.add(time.toString()); } }需要注意LocalTime的循环条件,我最开始写成time.isBefore(closeTime),结果漏掉了最后一个开始时间刚好使结束时间等于关门时间的槽位,后来调了半天才意识到要加上equals(closeTime)的判断。
4.3 个人中心与会员余额展示
个人中心页面说白了就是我的信息、我的余额、充值入口、预约记录列表。余额展示用了一个“刷新后重新拉取”的模式,因为用户充值或者消费后,余额可能变了,必须在onShow钩子里重新请求用户信息,而不能只在onLoad拉一次。这一点我踩过坑:用户充完值返回个人中心,页面没刷新,余额还是老的,在小程序里onShow每次页面显示都会触发,所以把请求放在onShow里最稳妥。
预约记录列表要分状态展示:待确认、已确认、已完成、已取消、已爽约。前端可以用一个tab切换,或者用标签样式过滤。我给每个状态定义了一套颜色:待确认橙色、已确认绿色、已完成蓝色、已取消灰色。这个纯粹是个人喜好,但让页面看起来像回事。列表项需要显示预约的日期、时间、项目名、技师名、金额、状态,点击可查看详情或取消预约。取消预约按钮在状态为待确认或已确认时可见,点击后调用取消接口,后端将预约状态置为已取消,同时退款。
5. 常见问题与排查思路
5.1 微信登录 code2session 的坑
第一次写小程序登录,最常见的报错是errcode:40163 code been used。这个错误的含义是同一个code被用了两次。罪魁祸首通常是前端wx.login拿到了code,然后因为网络慢,用户重复点了登录按钮,导致同一个code发送了两次给后端。解决方法是:前端定义一个变量,请求期间置为true,请求完毕才允许再次请求;后端也可以把每次成功换取openid的code存起来,第二次遇到直接返回错误。
另外还有一个容易踩的坑:后端用HttpClient请求微信接口时,因为网络问题返回超时,程序没做重试,导致用户无法登录。建议使用带连接超时和读取超时的HTTP客户端,并且在超时后返回一个友好的提示“登录失败,请稍后重试”,而不是直接堆异常堆栈。
5.2 时间格式和时区问题
这个坑说多了都是泪。MySQL的datetime类型和小程序前端传过来的字符串之间,经常出现各种因为时区导致的偏移。我建议全项目统一采用“字符串传输、LocalDateTime接收、MySQL datetime存储”的方案,前端传给后端的时间格式一律yyyy-MM-dd HH:mm:ss,后端用@DateTimeFormat或Jackson的JsonFormat统一解析。尤其注意,不要在后端把LocalDateTime转成时间戳再返回给前端,因为前端new Date(时间戳)默认使用的是用户手机本地时区,一旦顾客和服务器不在同一个时区,整个预约时间全乱了。
另外在数据库里,日期字段appointment_date建议使用date类型而不是varchar,这样SQL里可以直接用DATE(start_time) = #{date}做条件,效率更高,也更严谨。
5.3 预约时间重叠的脏数据怎么排查
如果你发现预约数据偶尔出现重叠,先别急着改代码。最直接的方法是用一条SQL把冲突对找出来:
SELECT a1.id, a2.id FROM appointment a1 JOIN appointment a2 ON a1.technician_id = a2.technician_id AND a1.id < a2.id AND a1.start_time < a2.end_time AND a1.end_time > a2.start_time WHERE a1.status IN (0,1) AND a2.status IN (0,1);如果这条SQL查出了数据,说明并发控制没做到位。我排查时发现,当时我在Service层先查了一次是否有冲突,然后调用了另一个方法插入预约,结果这个“另一个方法”里没有事务,查和插之间隔了几十毫秒,两个并发请求都在对方插入前完成了检查,于是双双通过。加一个@Transactional在操作入口方法上,问题就解决了。所以排查事务问题,第一步永远是看方法调用链上有没有完整的事务边界。
6. 调试技巧与附件文档的撰写心得
6.1 小程序真机调试和本地后端联调
对于毕设演示,最怕出现“本地跑得好好的,一上演示就崩”的尴尬。我推荐你一定要提前进行真机预览。用微信开发者工具打开项目后,点“预览”生成二维码,手机扫码就能在真机上跑。但这时候后端必须能被手机访问到:后端启动时监听0.0.0.0,前端request的baseURL不要写localhost或127.0.0.1,而要写你电脑在局域网中的IP。用命令行ipconfig(Windows)或ifconfig(Mac)查看本机IP,比如192.168.31.24,然后小程序里的baseURL写成http://192.168.31.24:8080。注意,小程序必须在开发者工具里勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”,否则真机访问http接口会被拦截。这一点很多教程没讲清楚,实际上坑了无数人。
如果后端部署在服务器上,则确保服务器安全组开放相应端口,并且后端接口支持CORS跨域。SpringBoot项目很容易处理,写一个CorsFilter配置类或在Controller上加@CrossOrigin就行。
6.2 如何让“LW文档”显得有料
毕业设计一般要求提交论文或说明文档(这里说的LW文档就是论文/设计说明书),很多同学把系统写完就以为万事大吉,结果文档写得像流水账,被导师批得一无是处。我个人的经验是:文档的核心不是贴代码,而是讲清楚“为什么这样做”。比如在需求分析一节,你需要写按摩店当前人工管理的痛点,用具体的业务场景描述,例如“前台高峰时段电话预约易冲突、会员余额靠Excel手动更新易出错”;在系统设计一节,要重点说明预约冲突检测的方案选型,为什么使用“查询重叠区间+唯一索引”而不是“只查数据库有没有同一开始时间”;在数据库设计一节,要画出ER图并解释每个状态字段的含义,以及为什么要给appointment表建联合唯一索引。
我还在文档末尾加上了“系统测试”一节,内容包括功能测试用例和结果表。比如“测试用例:用户选择已满的时间段,系统应提示该时段不可预约,预期结果:提示成功,实际结果:通过”。别小看这种表格,答辩时老师一眼就能看出你有没有真正测试过。整个文档我写了大概八千多字,结构是:摘要、绪论、需求分析、总体设计、详细设计、系统实现、系统测试、总结与展望、参考文献。
6.3 答辩时可能会被问的高频问题
如果你把上面的代码都写出来,大概率会遇到老师这几个问题,提前准备好回答思路:
“预约冲突你是用什么机制解决的?”回答:数据库层面联合唯一索引保证同一开始时间不重复,业务层通过区间重叠判断SQL保证任意时间段不冲突,事务保证数据一致性。面试官如果追问SQL怎么写,把上面的
start_time < endTime AND end_time > startTime背出来。“如果用户支付一半,突然网络断了怎么办?”回答:事务保证了一旦出现异常,整个操作(扣余额、改订单、改预约)都会回滚,不会出现扣钱没预约成功的中间状态。
“你的会员余额是怎么保证不超扣的?”回答:使用数据库的
UPDATE ... SET balance = balance - amount原子操作,同一时间多个请求也不会出现余额变成负数,同时配合流水表记录每次变动,方便对账。“为什么用MyBatis而不是MyBatis-Plus?”回答:这个项目SQL较为复杂(例如时间段重叠判断),使用MyBatis的原生SQL更容易控制和优化。当然如果你实际用了Plus,那就讲Plus的代码简洁和内置分页。
我个人在实际操作中的体会是,这类毕业设计真正的难点不在“写代码”,而在“把零散的需求变成有边界、有状态、有约束的数据库模型”这个过程。我审过几次同学的代码,问题基本都出在预约表没有唯一索引、余额修改不是原子操作、事务放错方法上。只要你能把这三个点控制住,整个系统稳定性就会有一个肉眼可见的提升。这个项目后续还可以扩展的方向也很多,比如微信支付、短信提醒、技师服务评价、接单消息推送,甚至给店铺加一个经营数据看板。如果时间充裕,挑一两个加上,你的毕设分数会完全上一个档次。