最近帮人把一套基于Spring Boot的大学生体检预约小程序从零搭到上线,过程中踩了不少坑。这类“微信小程序 + Spring Boot单体”的组合在毕业设计里出现频率极高,但很多同学只是照着网上的模板改库名、改图片,答辩时问几个问题就废了。这篇文章我不打算只贴界面截图,而是把核心设计拆开讲:业务边界怎么定、数据库表怎么建才能支撑预约和取消、后端接口哪些地方容易出并发问题、小程序端登录和请求封装怎么做才规范。如果你准备拿这个题目做项目,或者正在给类似预约系统写答辩文档,这篇文章应该能让你少走很多弯路。
先说清楚一个事实:体检预约系统看起来业务简单,但它是典型的“麻雀虽小、五脏俱全”项目。它同时涉及用户身份识别、预约资源控制、状态流转、报告文件上传和权限隔离。把这些点做扎实了,代码量不大,却说得出设计逻辑,这才是毕业设计真正想考察的东西。
1. 别急着建表:先把业务流程和使用角色理清楚
1.1 校园体检的实际场景和我敲定的页面流
我做系统前先跑了一趟学校的校医院,观察了实际排队和登记流程,才确定这不是一套“通用医疗预约系统”。校医院体检的特点是:学生按学院分批次来,体检项目大多是固定组合套餐,现场要核对姓名和学号,体检完成后由医生统一上传报告,学生在小程序里下载查看。
基于这个现实,业务闭环可以收敛成一条主线:学生打开小程序查看体检套餐,选择日期和时段,填写真实姓名、学号、手机号,提交预约;管理员在小程序对应的管理后台发布套餐、设置每天名额、查看预约名单、取消未体检的预约;体检完成后上传PDF格式报告;学生端在“预约记录”里看到报告并打开预览。
所以我确定的小程序端页面就是四个主页面:首页展示体检套餐和公告,预约页选择日期时段和填写个人信息,记录页展示预约状态与报告入口,我的页面处理登录状态和个人资料。管理端不走小程序,而是做成一个独立的Web管理页面,前后端通过接口通信。很多同学把管理功能硬塞进小程序里做,或者让小程序用户直接看到管理菜单,这种做法会让角色权限混乱,答辩时很容易被问住。
1.2 功能边界划分:哪些功能不要做
体检预约系统最容易翻车的地方在于“什么都想加”。我看到过有同学在预约系统里加了在线支付、提醒短信、医生排班、心电图上传等一大堆模块,结果数据库十几张表,到了答辩却讲不清楚核心流程。
我最后只保留了四组核心功能:套餐管理、时段名额管理、预约记录管理和报告管理。聊天消息、推送通知、支付这些一概不做。原因很简单:学校体检通常是免费或线下缴费,小程序支付功能没有真实业务场景;短信提醒需要第三方服务商,在毕设环境里会产生额外成本。如果非要体现技术亮点,可以用Spring Boot自带的定时任务做“体检前一天预约提醒”的演示逻辑,但不接入真实短信。
角色上我分成三类:普通学生用户、体检管理员、系统管理员。学生通过微信授权进入,管理员账号由系统预置。学生不能访问管理端接口,管理员也不能在小程序端提交预约,这是通过后端拦截器和接口路径规划实现的,不是靠前端隐藏按钮。
1.3 技术选型的理由:为什么是Spring Boot 2.7.18 + 原生微信小程序
先说Spring Boot版本。这个项目我选的是2.7.18搭配JDK 8或11,而不是最新版本3.x。原因非常现实:Spring Boot 3要求JDK 17起步,很多学校机房电脑还在用JDK 8,而且网上能找到的MyBatis-Plus、Knife4j、Docker配置教程大多是基于2.x版本写的。用2.7.18可以最大程度减少环境兼容性带来的额外问题,也方便答辩现场直接用一台普通电脑跑起来。
小程序端我用的是原生微信小程序,不是uni-app。原生框架在预约表单、时间选择器、上传文件这些场景都有成熟组件,而且不用额外引入编译器。如果你本身熟悉Vue,想用uni-app写一套代码以后发到其他端,也不是不行,但要注意uni-app编译出来的包体调试时容易出一些莫名其妙的问题,比如自定义导航栏高度、canvas层级错乱等,反而是给毕业设计增加负担。
后端依赖选型上,我用Spring Boot Web + MyBatis-Plus + MySQL 8.0 + JWT。MyBatis-Plus的好处是单表CRUD基本不用写XML,预约记录的分页查询直接用LambdaQueryWrapper就能完成。JWT用来做小程序登录后的身份令牌,不要让用户每次请求都带着openid跑来跑去,这样不安全也不规范。
2. 核心数据表设计:预约系统能不能自圆其说就看这里
2.1 六张表的关系与职责
数据库是整个预约系统的地基,我前后大概调整了三次表结构才定下来。最终保留了六张核心表和两张辅助表:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| user | 小程序用户 | openid, 学号, 姓名, 手机号, 头像 |
| check_package | 体检套餐 | 套餐名称, 原价, 折扣价, 项目说明 |
| check_item | 体检明细项 | 项目名称, 项目备注 |
| package_item | 套餐明细关联 | package_id, item_id |
| time_slot | 体检时段 | 日期, 时间段, 总容量, 已预约数, 是否开放 |
| appointment | 预约记录 | user_id, package_id, slot_id, 状态, 预约编号 |
| health_report | 体检报告 | appointment_id, 报告编号, 报告文件URL, 结论 |
| admin_user | 管理后台账号 | 账号, 密码, 姓名, 角色 |
这里最容易被忽略的是time_slot表。很多简化版设计直接在预约表里存一个“date”和“time_scope”字符串字段,这样用户提交预约时根本没法和“名额上限”关联起来。正确的做法是单独建时段表,把某一天上午、某一天下午作为一条记录,每条记录里有capacity(总容量)和selected_count(已预约数)。这样管理员设置名额、用户选择时段、后端控制超卖都有了依据。
health_report表之所以要和appointment一对一关联而不是直接在预约表里存file_url,是为了满足体检报告可以反复上传更新的需求。管理员第一次上传后如果发现文件错了,需要重新上传而不影响预约记录的其他字段。
2.2 套餐明细为什么用中间表
如果只是应付展示,可以在check_package表里存一个“项目内容”的长文本字段,前端直接渲染出来。但认真做过项目的同学会发现,套餐和体检项目之间是多对多关系:一个套餐包含多项检查,一个检查项目也可能被多个套餐采用。
我用package_item中间表把这两个实体拆开。不做多余的动作,只存储套餐和项目的关联。这样做的好处是管理后台可以独立维护检查项目库,发布新套餐时勾选已有项目即可,不用每次重新编写说明文本。数据库文档里也能清楚画出一个多对多关系图,这在毕业设计论文里是很加分的部分。
建表时还有一个小设置值得提醒:所有和预约相关的表都加version字段或update_time字段。version字段用在时间段的乐观锁更新中,虽然我在项目中最终采用数据库行锁来控制名额,但保留version字段可以给你留一条调整方案的余地。
2.3 预约状态机:从初始提交到最终出报告
预约记录绝不是“有/无”两个状态。我设计了五个状态值,在代码里用整数常量表示:
| 状态值 | 含义 | 触发动作 |
|---|---|---|
| 0 | 已取消 | 用户取消或管理员取消 |
| 1 | 待体检 | 用户提交预约成功 |
| 2 | 已完成待报告 | 管理员标记学生已到场完成体检 |
| 3 | 报告已出 | 管理员上传报告 |
| 4 | 已过期 | 定时任务将过期未体检记录置为失效 |
状态流转的约束是:只有状态为1的记录才能被用户取消;只有状态为2的记录才能上传报告;状态为3后不能回退到2。这些规则放在后端Service层校验,前端除了按钮显隐,不能作为安全依据。
很多人会问,为什么需要“已完成待报告”这个中间状态?因为学生到现场完成体检和医生出具报告是两件事,中间往往隔着几天。如果没有这个状态,管理员就只能从“预约列表里挑人上传”,无法快速筛出“今天已经到场但还没出报告”的名单。从答辩角度看,这样讲状态设计也很有层次。
3. Spring Boot后端实现:登录、JWT和预约事务
3.1 微信登录的完整流程,不只是调一个接口
微信小程序登录是整个系统用户体系的基础。正确流程是:小程序端调用wx.login拿到临时code,把code传给后端;后端拿着code加上小程序的AppId和AppSecret,请求微信的jscode2session接口,换取openid和session_key;后端用openid去user表查找用户,找不到就自动注册;最后签发一个自定义登录态,也就是JWT,返回给小程序端。
这里有一个特别需要强调的安全点:AppSecret绝对不能出现在小程序前端代码里。如果写在wx.request的URL参数中,任何人只要能查看小程序请求记录就能拿到你的密钥。正确做法是把AppSecret放Spring Boot的application.yml里,用RestTemplate或Hutool的HttpUtil调用微信接口。拿到的openid是小程序用户唯一标识,同一用户在不同小程序下的openid是不一样的,如果将来要开放公众号登录,可以考虑引入unionid体系,但校园体检场景没有这个必要。
Service层核心逻辑大概是这样:
code复制String url = "https://api.weixin.qq.com/sns/jscode2session?appid="
+ appid + "&secret=" + secret + "&js_code=" + code
+ "&grant_type=authorization_code";
String result = restTemplate.getForObject(url, String.class);
JSONObject json = JSONUtil.parseObj(result);
String openid = json.getStr("openid");
拿到openid后的用户注册要处理一个细节:用户第一次登录还没有姓名学号,你不能阻塞整个登录流程要求他先填资料。我的做法是用户表里openid、手机号、学号都允许为空,小程序端用户进入“我的”或提交预约时,再通过资料补全接口更新。这样登录速度和用户体验都有保障。
3.2 JWT拦截器与接口权限设计
签发JWT时我用用户id和openid作为payload,加上过期时间,用HMAC-SHA256签名。虽然HS256密钥方式在大型项目里已经不太够用,但对于单体毕业设计项目来说完全够用,也比每次请求都查数据库判断用户登录状态要高效得多。
我写了一个AuthInterceptor,把它注册到Spring MVC拦截器链里。所有以/api/user/开头的业务接口都会经过拦截器校验,而/api/user/login、/api/admin/login和静态资源路径需要放行。小程序端请求时在Header里设置Authorization: Bearer <token>,后端解析如果签名正确且未过期,就把用户id放到ThreadLocal或Request属性中,方便Service层直接获取当前登录用户。
这里有个常见的坑:有人把token校验逻辑写在每个Controller方法里重复调用,代码显得非常臃肿,更重要的是很容易漏掉某个接口。用拦截器统一处理之后,后期新增接口会自动落入权限保护范围,这是“设计感”的直接体现。
管理后台接口我单独使用/api/admin/前缀,并配置另一套JWT拦截逻辑,校验当前用户是否为管理员角色。若想图方便,可以在接口内判断request属性中用户角色码,但我实际项目里还是做成了两个不同拦截器路径,逻辑上更清晰,文档里面写权限设计也更好画图。
3.3 预约提交为什么必须加事务锁
预约提交是核心接口,也是最容易出并发问题的地方。很多同学的实现是:Service层先查询时段剩余名额,如果大于0就执行insert,然后更新已预约数量。你单独点一次没任何问题,但两个人同时提交最后一个名额时,就可能出现两个请求都读到剩余名额为1,然后都成功插入,最后实际超卖了一个。
我在实现时先把当前用户是否有相同时间段的有效预约查一遍,再把time_slot记录用数据库行锁锁住,在锁内重新读取预约人数并判断容量。关键代码抽象出来是这样的:
code复制@Transactional
public Long createReservation(ReservationCreateCommand cmd) {
User currentUser = UserContext.get();
TimeSlot slot = timeSlotMapper.selectByIdForUpdate(cmd.getSlotId());
if (slot == null || slot.getStatus() != 1) {
throw new BizException("该时段未开放");
}
if (slot.getSelectedCount() >= slot.getCapacity()) {
throw new BizException("当前时段已约满");
}
long exists = appointmentMapper.selectActiveCount(
currentUser.getId(), cmd.getSlotId());
if (exists > 0) {
throw new BizException("您已预约过该时段,请勿重复预约");
}
appointmentMapper.insert(buildAppointment(...));
timeSlotMapper.increaseSelectedCount(cmd.getSlotId());
return appointment.getId();
}
这里的selectByIdForUpdate是MyBatis-Plus自带注解支持的行锁查询。它的原理是事务在读取该时段记录时对数据库该行加排他锁,直到事务提交或回滚才释放。第二个事务如果同时操作同一时段,就必须等前一个事务结束后才能读取。正因如此,判断名额和插入预约之间不会再被其他请求插入干扰,超卖问题就解决了。
很多初学者会担心行锁会不会影响性能。对于校医院体检这种一天最多几百预约的业务场景,行锁带来的并发开销完全可以忽略,但换来的是数据正确性。你论文里如果要写“高性能并发控制”,业务规模和技术方案不匹配反而会被老师质疑,所以千万不要乱写“高并发秒杀”这类词。
3.4 取消预约时的名额回补与状态条件
取消预约接口同样要处理状态和名额回补。按我的状态机设计,只有状态为“待体检”的预约记录支持用户取消,状态为“报告已出”的预约不能取消,否则报告对应关系就乱了。
取消操作必须放在一个事务里完成两步:一是把预约状态从1改成0,二是把对应时段已预约数量减一。如果只改了预约状态忘了改时段人数,后面就会出现“明明没人约但显示已满”的尴尬情况。这也是我在代码评审时发现的高频bug。
管理员取消预约的场景类似,但需要在管理后台记录cancel_reason,便于后续核对。为了防止多人同时取消造成负数,更新已预约数时我用的是SQL层面的减操作:update time_slot set selected_count = selected_count - 1 where id = ?,而不是先查出来减一后再更新,这样即使同一时段被两个取消操作并发触发,也不会出现负数。
4. 微信小程序端:从登录到提交预约的工程化写法
4.1 页面规划和自定义导航栏
我个人强烈建议把这个预约小程序按“首页 + 记录页 + 我的页”三个TabBar来设计,预约功能嵌在首页的推荐体检套餐里进入二级页面。不要单独做一个预约Tab,因为只有当你选择了某个套餐后才有预约动作,单独Tab会让用户在一个空表单里无所适从。
首页主要展示推荐套餐卡片,后端提供列表查询接口,小程序端使用wx.request获取后渲染。预约详情页展示套餐内容、剩余时段选择器、个人信息填写区域。时段选择器我建议用微信小程序原生的picker组件,mode="date"选择日期后,再渲染当天开放的上午/下午时段按钮,比用checkbox模拟日期日历要稳定很多。
导航栏可以直接用系统默认的,不一定要自定义。如果你非要自定义顶部导航栏以获得更好看的效果,就需要在app.json里设置"navigationStyle": "custom",然后自己计算状态栏高度和胶囊按钮位置。很多同学到这一步就卡住了,因为不同手机型号上状态栏高度不一样。我的建议是不到万不得已别在毕业设计里做自定义导航,默认导航栏只要是白底黑字已经足够清爽。
4.2 请求封装:统一处理token和错误码
原生小程序里发起网络请求的API是wx.request,但它是基于回调函数写的,如果每写一个页面就直接调用,代码会变成一堆嵌套回调,非常难维护。我在项目里封装了一个request.js工具,把异步调用包装成Promise。
核心处理逻辑包括:从wx.getStorageSync("token")读取token并写入请求头;服务端如果返回业务码401,则说明token失效,清除本地登录状态并跳转到登录页;如果返回其他业务码,直接弹出Toast展示msg;网络不通时统一提示“网络连接异常”。举一个简化后的写法:
code复制function request(url, method, data) {
return new Promise((resolve, reject) => {
wx.request({
url: baseUrl + url,
method: method,
data: data,
header: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + wx.getStorageSync('token')
},
success: (res) => {
if (res.data.code === 200) {
resolve(res.data.data);
} else if (res.data.code === 401) {
wx.removeStorageSync('token');
wx.navigateTo({ url: '/pages/login/login' });
reject(res.data);
} else {
wx.showToast({ title: res.data.msg, icon: 'none' });
reject(res.data);
}
},
fail: (err) => {
wx.showToast({ title: '网络请求失败', icon: 'none' });
reject(err);
}
});
});
}
这套统一封装看着不起眼,但它保证了小程序端代码的结构统一,也给后端的接口约定定了规范。后端返回的数据结构也需要格式化为{ code, msg, data }这样的结构,不要出现一个接口直接返回数组、另一个接口返回对象的情况。
4.3 用户信息填写与头像昵称的改动
如果你最近在做小程序,肯定知道微信已经把wx.getUserProfile获取头像昵称的方式收紧了。现在要获取用户头像和昵称,需要在小程序端使用button的open-type="chooseAvatar"和输入框的nickname类型。
体检预约表单里至少要让用户填写真实姓名、学号、手机号。这里的手机号不要用微信手机号快捷验证,因为那需要企业认证的小程序账号,个人主体或未认证账号根本用不了。正确做法是在表单里放一个普通输入框做手机号校验,前端用正则校验11位数字,后端再校验一次。
头像和昵称属于“锦上添花”的字段,不强求。如果用户没有完善头像,显示默认头像即可。预约记录页面需要识别的是用户姓名和学号,这些数据在预约提交时已经同步到预约表里,而不是每次实时从user表查询。为什么这么做?因为如果管理员已经下载预约名单后用户才修改了资料,名单上显示的应该还是预约时的信息,这更符合线下核对场景。
4.4 提交预约前的防重复操作
预约提交按钮的重复点击是前端最容易踩的坑。请求发出后网络有延迟,如果用户以为没点上又点了一次,就可能向后端发送两条一样的预约请求。前端在提交点击后立即把按钮设为loading或disabled状态,等接口返回后再恢复。核心代码如下:
code复制submitReservation() {
if (this.data.submitting) return;
this.setData({ submitting: true });
request('/api/user/reservation/create', 'POST', params)
.then(() => {
wx.showToast({ title: '预约成功', icon: 'success' });
wx.redirectTo({ url: '/pages/records/records' });
})
.finally(() => {
this.setData({ submitting: false });
});
}
即便前端做了防重复,后端也必须依然保留前面介绍的行锁和重复校验。前端防重复是为了体验,后端防重复才是保底,两者并不冲突。在最终演示的时候,你甚至可以在后端接口里临时写一个延迟逻辑来模拟慢网络,然后快速点击按钮展示前端不会发出重复请求,这会是很棒的演示细节。
5. 我在真机测试中踩过的那些坑
5.1 重复预约排查实录
有一轮测试时,我发现测试账号提交预约后,后台列表出现了两条一模一样的记录。第一反应是前端按钮没加禁用,后来检查请求日志发现确实只发了一次请求,问题出在后端接口被调用方重试,也可能是因为学生用户手动刷新导致同一请求重复到达。
我打开后端日志继续看,发现两条预约记录插入成功但中间并没有报错。原因是我后端的重复校验用的是普通查询加上insert,两个线程同时执行时,查询都发现没有该时段的有效预约,然后都走到了insert。也就是典型的“检查与插入之间没有原子保护”。
后来我把预约提交改为事务,并在插入前对时段记录执行selectByIdForUpdate,让并发请求串行化。修改后再次用Jmeter并发20个线程测试,最终只生成了一条有效预约,其余请求都返回了“您已预约过该时段”。这个排查过程让我意识到,事务和锁的知识不是应付面试的八股文,遇到真实重复数据时,它是唯一能快速定位问题的手段。
5.2 取消预约后名额不恢复
另一次问题是用户取消预约后,管理员那边统计时段已约数量没有减一。当时我检查了取消接口,发现里面只更新了appointment表的status字段,压根没有调用更新time_slot表的代码。这是因为我在写取消功能时只想着“标记取消”,没有把关联的“名额回补”当成同一业务动作的一部分。
修复方法很简单,把两步更新放进同一个事务方法。真正值得反思的是为什么会出现这种遗漏:因为我最初的设计是照着接口清单逐个写,而不是从业务用例出发。后来我改成先画业务操作的状态迁移图,再对照状态迁移补充每个操作涉及的底层数据变更,就再没犯过这种少了半截逻辑的问题。
5.3 日期上的时区陷阱
测试中还出现过一次比较隐蔽的问题:管理员在后台上传报告,小程序端显示的上传日期比实际日期早了8小时。这是因为Spring Boot默认的时区和MySQL连接的时区设置不一致。如果JVM时区是UTC,而MySQL连接串里没有显式指定serverTimezone,就会导致日期字段存取出现偏差。
解决办法是在数据库连接串里显式写上serverTimezone=Asia/Shanghai,并在启动类或配置文件里统一指定spring.jackson.time-zone=GMT+8。如果项目要部署到云服务器,服务器的系统时区也建议设置成Asia/Shanghai。这问题平时不显眼,一旦出现就很难让第一次接触的人看出来。
6. 打包部署与会前自查清单
6.1 Spring Boot项目打包和配置迁移
系统开发完成后的交付过程同样有很多细节。Spring Boot项目我建议用Maven打包成可执行jar而不是war包,避免再去配置外部Tomcat。打包命令就是:
code复制mvn clean package -DskipTests
配置文件里数据库密码、AppSecret、JWT密钥这类敏感信息,不要直接写在源码注释里到处发,至少要在文档里说明哪些配置需要按环境修改。更讲究一点的做法是使用spring.profiles.active区分开发环境和生产环境,但毕业设计不强制,能把一个application.yml里的占位符解释清楚已经足够。
数据库脚本要单独保存一份初始化SQL,包含建库语句、建表语句和基础管理员账号插入语句。这样评审老师拿到项目后,不需要依赖你本地数据库备份也能一键跑起来。
6.2 小程序发布前的小程序后台配置
小程序端在模拟器中跑通只是第一步,真机预览和上线前还需要在微信公众平台配置合法域名。
开发阶段有两个办法绕过限制:一是在微信开发者工具里勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”;二是用手机和电脑连同一个局域网,在开发工具里开启“真机调试”。但正式发布或体验版权限较大地分发给别人时,必须在公众平台“开发管理-开发设置-服务器域名”中把后端接口域名填入request合法域名。域名必须支持HTTPS,并且需要上传校验文件完成归属验证。
如果后端只在本地跑,只能临时用内网穿透工具把本地端口映射成一个公网HTTPS地址。这种方式用来给老师演示没问题,但不建议长期作为正式环境,因为免费穿透的稳定性无法保证,而且数据安全性不好。
6.3 验收演示前我会检查的六个问题
每次准备答辩或交付前,我习惯按下面这个清单完整走一遍:
第一,新用户首次进入小程序能不能正常登录并自动注册,后端user表是否出现该openid记录;第二,用户不填姓名直接点提交会不会被正确拦截;第三,同一个时段被约满后,前端时段选择按钮是否置灰,手动构造请求后端是否还能拒绝;第四,用户主动取消预约后,管理员端的已约数量是否立即减一;第五,管理员上传报告后,用户端记录页在没有手动下拉刷新时是否可以提醒更新;第六,预约记录列表分页加载时,滚到底部能不能加载下一页而不是重复请求第一页。
这几个问题基本覆盖了系统最容易受到质疑的业务场景。即便代码写得不那么华丽,把这些问题都以真实数据跑通并讲清原理,比空谈架构设计更有说服力。
我对这类项目的最终体会是:Spring Boot负责提供稳定的数据和业务规则,小程序负责把复杂逻辑转成用户看得懂的界面。不要把做项目理解成“堆代码”,先把预约闭环中的每一个状态变化、每一次资源增减都写在纸面上,再动手写映射关系,你会发现代码实现比想象中痛快得多。如果你正在做类似题目,可以从我这里拿一张表结构草图和接口清单当底稿,但一定要自己把异常流程完整走一遍,那才是属于你的真正收获。
