又到了毕业设计季节,身边已经有好几个学弟学妹在问同一个问题:想做宠物医院相关的小程序,后端用SpringBoot,前端走微信小程序,到底该怎么下手。说真的,这个选题在计算机毕业设计里算是非常稳的一类——需求清晰、技术栈经典、功能模块容易扩展,而且演示效果好,答辩的时候能讲的东西很多。我自己带过几届毕设,也帮人改过不少这类项目,踩过不少坑,今天干脆把整套思路、表结构、核心代码、上线注意事项一次性写清楚。
这篇东西不仅适合完全零基础的小白照葫芦画瓢,也适合已经写了点皮毛但卡在某个环节的同学。全文围绕“SpringBoot做后端接口 + 微信小程序做C端页面”这条主线展开,把从选题分析、数据库设计到接口实现、联调上线、答辩准备的整条链路全部拆开讲。
1. 选题分析与技术架构设计
1.1 为什么宠物医院小程序是毕业设计的“安全牌”
先聊聊选题。很多同学一上来就纠结做什么题目,其实判断标准很简单:一看数据模型是否足够丰富,二看业务逻辑有没有深度,三看演示是否直观。宠物医院这个场景三样全占。
用户端有注册登录、宠物档案、在线预约、问诊咨询、支付订单、评价反馈;医生端有坐诊安排、接诊记录、病历填写;管理端有医生管理、科室管理、预约审核、数据统计。光这一套下来,涉及的数据表少说十几张,增删改查全覆盖,还有状态流转、事务处理、权限拦截这些“加分项”,答辩时随便讲一个模块都能讲五分钟。
再一个原因,宠物医院天然带有“预约”这个核心业务。预约涉及时间选择、医生排班、状态流转(待确认→已确认→已完成→已取消),这种有时序逻辑的功能比普通的增删改查有含金量得多,也是评委最爱的提问点。
1.2 技术栈选择:SpringBoot + 小程序原生
后端我推荐使用SpringBoot 2.7.x版本,搭配MyBatis-Plus做ORM、MySQL 8.0做存储、Redis做缓存和token存储、JWTs做接口鉴权。这个组合是目前毕设项目里最主流的,资料多、报错容易搜、面试问起来也有话说。
注意:不要一上来就追新用SpringBoot 3.x。3.x要求JDK17,很多学校机房旧电脑装的还是JDK8,而且部分老教程里的写法完全不兼容,光是javax到jakarta的迁移就能折腾一晚上。毕设求稳,SpringBoot 2.7 + JDK8是最稳妥的组合。
小程序端我建议直接用原生开发,不推荐在这个项目里上uniapp或Taro。原因很简单:毕业设计只做微信小程序一个端,用不着跨端框架;原生开发的wxml、wxss、js结构清晰,代码量可控,调试时直接看微信开发者工具的控制台,排查问题效率最高。如果你以后想扩展App端,才值得考虑uniapp,这个后面再细说。
1.3 系统功能模块全拆解
整个系统按角色划分成三个端,功能边界要清晰,这是后面前后端联调不吵架的关键。
用户端功能:
- 微信登录与手机号绑定
- 宠物档案管理(添加多只宠物、维护品种、年龄、疫苗记录)
- 在线预约挂号(选择科室→选择医生→选择时间段→提交预约)
- 在线问诊(图文描述病情、查看医生回复)
- 支付结算(微信支付,毕设可做模拟支付)
- 预约记录、问诊记录、个人中心
医生端功能(小程序内部另一个tab或独立入口):
- 查看今日预约列表
- 接诊与病历填写
- 处理问诊咨询、发布回复
管理后台功能(Web端,可用Vue或直接复用SpringBoot的模板引擎):
- 医生账号管理、科室管理
- 预约审核、排班管理
- 公告发布、数据统计看板
这个划分意味着后端需要把接口设计成三套:小程序C端接口、医生端接口、后台管理接口。虽然接口数量多,但每个接口的职责都很单一,这也是SpringBoot项目分层清晰的优势所在。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与表结构落地
2.1 核心实体梳理
开始建表之前,先把实体之间的关系理清楚。我习惯用一句话描述每个实体——“谁在什么时间约了哪个医生的哪个时段”,从这个描述里拆出实体:
- 用户(user)
- 宠物(pet)
- 医生(doctor)
- 科室(department)
- 排班(schedule)
- 预约单(appointment)
- 问诊单(consultation)
- 病历(medical_record)
用户和宠物是一对多关系,用户和预约单是一对多关系(一个用户往往有好几只宠物),排班和预约单是一对多关系,预约单和病历是一对一关系。
2.2 关键表结构设计详解
下面给出五张核心表的建表SQL,这些字段是我实际项目里反复调过的,直接抄就行。
用户表:
sql复制CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`openid` varchar(64) NOT NULL COMMENT '微信openid,唯一标识',
`nickname` varchar(50) DEFAULT '' COMMENT '昵称',
`avatar_url` varchar(255) DEFAULT '' COMMENT '头像地址',
`phone` varchar(11) DEFAULT '' COMMENT '手机号',
`real_name` varchar(50) DEFAULT '' COMMENT '真实姓名',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid` (`openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序用户表';
宠物档案表:
sql复制CREATE TABLE `pet` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '所属用户id',
`name` varchar(50) NOT NULL COMMENT '宠物昵称',
`species` tinyint(4) DEFAULT NULL COMMENT '物种:1狗 2猫 3其他',
`breed` varchar(50) DEFAULT '' COMMENT '品种',
`gender` tinyint(4) DEFAULT NULL COMMENT '性别:1公 2母',
`birthday` date DEFAULT NULL COMMENT '生日',
`avatar_url` varchar(255) DEFAULT '' COMMENT '宠物头像',
`vaccine_info` varchar(500) DEFAULT '' COMMENT '疫苗记录,简要文字',
`delete_flag` tinyint(1) DEFAULT 0 COMMENT '逻辑删除标记',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物档案表';
这里有个容易踩的坑:宠物表必须加delete_flag逻辑删除字段,千万不要物理删除。因为宠物档案关联着预约记录和病历,一旦物理删除,历史数据就全乱了。MyBatis-Plus自带逻辑删除配置,用起来并不麻烦。
预约表:
sql复制CREATE TABLE `appointment` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '预约单号',
`user_id` bigint(20) NOT NULL,
`pet_id` bigint(20) NOT NULL,
`doctor_id` bigint(20) NOT NULL,
`department_id` bigint(20) DEFAULT NULL,
`appointment_date` date NOT NULL COMMENT '预约日期',
`time_slot` varchar(20) NOT NULL COMMENT '时间段,如09:00-09:30',
`status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已完成 3已取消',
`symptom_desc` varchar(500) DEFAULT '' COMMENT '症状描述',
`cancel_reason` varchar(200) DEFAULT '' COMMENT '取消原因',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_doctor_date` (`doctor_id`, `appointment_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约挂号表';
预约表是整项目里最核心的表。注意我给(doctor_id, appointment_date)建了联合索引,因为在“某医生某天的预约列表”这个高频查询场景下,联合索引能大幅减少扫描行数。同理,order_no一定加唯一索引,前端提交时用这个单号做幂等控制,防止用户重复点击造成重复预约。
医生排班表:
sql复制CREATE TABLE `doctor_schedule` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`doctor_id` bigint(20) NOT NULL,
`work_date` date NOT NULL COMMENT '出诊日期',
`time_slot` varchar(20) NOT NULL COMMENT '时间段',
`total_count` int(11) DEFAULT 10 COMMENT '该时段总号源',
`used_count` int(11) DEFAULT 0 COMMENT '已占用号源',
`day_of_week` tinyint(4) DEFAULT NULL COMMENT '星期几,1-7',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_doctor_slot` (`doctor_id`, `work_date`, `time_slot`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表';
设计排班表的时候我吃过亏,最开始只记录了日期和医生,没有细分时间段,结果一个上午的号全混在一起,根本无法判断具体哪个时间点被约满了。后来改成“医生 + 日期 + 时间段”这种粒度,预约时先查used_count < total_count再插入预约记录,并且在同一个事务里将used_count加一,才算把并发问题缓解住。虽然用户量上来以后还得靠悲观锁或Redis原子操作,但毕设场景里这套设计已经足够讲清楚“怎么解决超卖”这个问题。
3. SpringBoot后端核心实现
3.1 项目骨架搭建
SpringBoot项目我没用IDE自带的初始化器,而是推荐直接去Spring Initializr网站生成,选好版本和依赖后下载导入。不过你如果愿意用IDEA的Spring Initializr插件也行,区别不大。
关键依赖就这几个:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
注意MyBatis-Plus和SpringBoot版本要匹配,我上面给的是3.5.3.1,配合SpringBoot 2.7实测没问题。如果你用了3.5.9版本,也可能遇到Invalid bound statement之类的报错,到时候先检查Mapper扫描路径。
项目包结构按controller → service → mapper → entity四层来分,再单独加一个config包放全局配置、一个common包放统一返回体和异常处理。为什么这么分?因为后期接口越来越多,如果所有业务逻辑都堆在Controller里,维护起来就是灾难,答辩时写代码的老师问一句“你这个Service层是干什么的”,你也讲不清楚。
3.2 微信登录与JWT授权实现
微信小程序的登录流程跟传统的账号密码登录完全不同。用户在小程序里点击授权后,前端调用wx.login()拿到一个临时code,然后把它发给后端。后端拿这个code去微信接口服务换取openid和session_key,这个过程中用户是无需输入任何账号密码的。
在SpringBoot里写一个AuthController,核心逻辑如下:
java复制@PostMapping("/wx/login")
public Result login(@RequestBody LoginRequest req) {
// 1. 用code调用微信接口换取openid
String url = "https://api.weixin.qq.com/sns/jscode2session"
+ "?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + req.getCode()
+ "&grant_type=authorization_code";
String result = restTemplate.getForObject(url, String.class);
JSONObject obj = JSON.parseObject(result);
String openid = obj.getString("openid");
// 2. 查数据库,不存在则自动注册
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getOpenid, openid));
if (user == null) {
user = new User();
user.setOpenid(openid);
userMapper.insert(user);
}
// 3. 签发JWT token
String token = JWT.create()
.withClaim("userId", user.getId())
.withExpiresAt(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000))
.sign(Algorithm.HMAC256("your-secret-key"));
return Result.ok(token);
}
然后写一个拦截器,对所有/api/user/**、/api/order/**开头的接口做token校验。JWT的好处是服务端无状态,不需要把session存在内存里,而且token里直接携带了userId,后续接口取当前登录用户特别方便。当然JWT也有短板,就是无法在服务端主动让它失效,所以真上线时要配合黑名单机制,但毕设里你可以主动把这点讲出来作为“未来优化方向”,反而是加分项。
小程序端拿到token以后存在wx.setStorageSync('token', token)里,每个请求的header带上Authorization: Bearer <token>。最严谨的做法是把这段请求逻辑封装成一个request.js工具函数,统一在拦截器里处理token过期跳转。
3.3 预约挂号接口与并发防重
预约是核心功能,接口设计必须考虑两个问题:同一时间段不能重复预约、同一用户不能重复提交。
后端实现用事务解决,一个方法搞定:
java复制@Transactional(rollbackFor = Exception.class)
public Appointment createAppointment(CreateAppointmentReq req, Long userId) {
// 1. 校验用户是否已有同时间段预约
Long count = appointmentMapper.selectCount(new LambdaQueryWrapper<Appointment>()
.eq(Appointment::getUserId, userId)
.eq(Appointment::getAppointmentDate, req.getDate())
.eq(Appointment::getTimeSlot, req.getTimeSlot())
.in(Appointment::getStatus, 0, 1));
if (count > 0) {
throw new BizException("您已预约该时段,请勿重复预约");
}
// 2. 乐观锁扣减号源
DoctorSchedule schedule = scheduleMapper.selectById(req.getScheduleId());
if (schedule.getUsedCount() >= schedule.getTotalCount()) {
throw new BizException("该时段已约满");
}
int update = scheduleMapper.updateUsedCount(schedule.getId(), schedule.getUsedCount());
if (update == 0) {
throw new BizException("该时段已被抢约,请选择其他时间");
}
// 3. 生成预约单
Appointment appointment = new Appointment();
appointment.setOrderNo(generateOrderNo());
// ... 其他字段赋值
appointmentMapper.insert(appointment);
return appointment;
}
updateUsedCount这条SQL是关键,它执行的是UPDATE doctor_schedule SET used_count = used_count + 1 WHERE id = ? AND used_count = ?。靠这个条件判断,两个并发请求同时进来时只有一个能更新成功,从数据库层面挡住了超卖。事务注解加在方法上,任何一个步骤抛异常,整个预约流程都会回滚,不会出现号源扣了但订单没生成的情况。
生成订单号我用的规则是“yyyyMMddHHmmss + 4位随机数”,自己拼字符串就行,不用依赖雪花算法。把生成逻辑放在一个公共工具类里,避免重复代码。
4. 微信小程序端关键实现
4.1 原生开发的目录组织与请求封装
小程序端的代码组织直接决定后期改bug效率。我的习惯是:pages下按功能模块分目录,比如pages/home、pages/appointment、pages/record,每个目录里放下wxml、wxss、js、json四个文件;utils目录放公共工具;components目录放自定义组件。
请求封装放utils/request.js,整个项目所有请求都走这一个入口:
javascript复制const BASE_URL = 'https://yourdomain.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.data.code === 200) {
resolve(res.data.data)
} else if (res.data.code === 401) {
wx.removeStorageSync('token')
wx.navigateTo({ url: '/pages/login/login' })
} else {
wx.showToast({ title: res.data.msg, icon: 'none' })
reject(res.data)
}
},
fail: (err) => {
wx.showToast({ title: '网络错误', icon: 'none' })
reject(err)
}
})
})
}
module.exports = { request }
统一封装的好处太多了:拦截401跳转登录、统一处理错误toast、上线之后换域名只改一个变量。我见过太多学弟把wx.request直接写在页面里,每个页面的错误处理各写各的,稍微改一下后端返回格式就要全局搜索改代码。
4.2 登录授权与个人信息维护的坑
微信小程序从2022年之后对用户隐私这块收得非常紧。以前wx.getUserProfile弹窗就能拿昵称头像,现在基础库2.21.2开始,头像昵称都要用button的open-type="chooseAvatar"和type="nickname"来让用户主动填写,不能静默获取了。
所以登录页的设计逻辑应该是:先wx.login()静默拿code完成登录(这一步不需要用户点任何按钮),拿到token之后进首页;用户进“个人中心”时再引导完善昵称头像。千万别在小程序刚进入时就弹一堆授权框,用户一烦就直接卸载了,而且审核时也会因为“强制授权”被拒。
手机号获取也变了,现在要用<button open-type="getPhoneNumber">配合后端接口二次解密才能拿到完整号码。毕设如果不太想折腾,手机号这个功能可以先做成用户手动输入,加个短信验证码逻辑,或者干脆做成选填,千万别卡在获取手机号这一步浪费两三天时间。
4.3 预约页面的交互实现
预约页面的交互是整个小程序端最复杂的。要选科室、选医生、选日期、选时间段,还要显示当前时段剩余号源。
日期选择我用的是picker组件,mode="date",同时限制start为今天。医生列表根据科室联动,时间段根据医生排班接口返回渲染。用户选完时段后,把scheduleId、petId、symptomDesc一起提交。这里有个小细节:提交前在前端也要做一次“该时段已约满”的检查,虽然后端有兜底,但提前拦截能让用户体验好很多,总不能让用户填完所有信息点提交才被告知没号了。
渲染时段列表的wxml大致长这样:
html复制<view class="slot-list">
<view
wx:for="{{slotList}}"
wx:key="id"
class="slot-item {{item.used >= item.total ? 'disabled' : ''}}"
bindtap="selectSlot"
data-id="{{item.id}}"
data-time="{{item.timeSlot}}"
>
<text>{{item.timeSlot}}</text>
<text class="count" wx:if="{{item.used < item.total}}">剩余{{item.total - item.used}}号</text>
<text class="count" wx:else>已约满</text>
</view>
</view>
disabled样式记得做灰色处理,并且已满的view不要绑定点击事件,或者绑了也要在selectSlot函数里再判断一次。
5. 调试、部署与常见问题排查
5.1 微信开发者工具的本地调试技巧
前后端联调是毕设过程中最容易崩心态的阶段,问题大多出在“域名”这件事上。
微信小程序正式环境要求所有网络请求的域名必须在小程序后台配置为合法域名,而且必须HTTPS。但开发阶段你不可能每次改后端都部署一次,这时候在微信开发者工具里勾选“不校验合法域名、web-view(业务域名)、TLS版本以及HTTPS证书”就行。勾选之后,请求本机的http://localhost:8080依然会报错——因为localhost在小程序模拟器里指的是开发者工具所在电脑,真机预览时指的是手机自己。所以真机调试时,后端地址要填你电脑在局域网里的IP,比如http://192.168.1.101:8080,手机和电脑在同一WiFi下才能通的。
注意:后端SpringBoot默认只监听localhost,局域网访问不了。在
application.yml里加上server.address: 0.0.0.0才能让局域网设备访问。
真机调试时优先用“真机调试”按钮,别用预览二维码。预览模式不会打日志,报错信息只能看小程序的vConsole,不够直观。真机调试模式能看到完整的后端请求日志和返回内容,排查问题效率翻倍。
5.2 部署上线与小程序审核
后端部署我推荐直接买一台最便宜的云服务器,装个宝塔面板,把SpringBoot打成的jar包扔上去用nohup java -jar跑。数据库装MySQL,本地导出的SQL直接导入云数据库。域名备案那一步最耗时,能早弄就早弄,很多同学就是被备案拖延症搞得最后项目没法上线。
小程序端发布前要做几件事:
- 所有请求域名改成HTTPS正式域名,且域名已经在小程序后台配置
- 隐私保护指引里把“用户信息”“手机号”等收集项如实填写
- 类目选择“医疗 > 宠物医院”,需要上传相关资质,如果资质不全,可以用“生活服务 > 生活服务”类目先顶着,但医疗相关功能可能过不了审
- 提交前用自己的测试账号把核心流程全部走一遍,特别是支付流程,一旦审核期间报支付故障,轻则驳回,重则限制支付能力
这里提醒一个很多人忽略的问题:SpringBoot上传文件时如果没有在application.yml配置文件大小限制,默认是1MB,宠物头像传一张高清照片就报错。配上这两行:
yaml复制spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
6. 毕设答辩高频问题与项目亮点包装
6.1 答辩前必须会的几个“为什么”
答辩时评委最烦的就是学生只演示不说话。演示要边点边说“这里做了什么”“背后用了什么技术”“为什么这么设计”。提前自问自答这三组问题:
第一组:你为什么用SpringBoot而不是SSM?答:SpringBoot简化了大量配置,内嵌Tomcat容器,项目启动更快、部署更方便,同时仍然保留了Spring的IoC和AOP能力。强调“自动配置”和“起步依赖”两个核心概念。
第二组:你是怎么解决预约超卖问题的?答:数据库乐观锁 + 事务 + 唯一索引三层保证。先通过排班表的used_count条件更新防止号源超卖,再通过事务保证预约单和号源扣减的一致性,最后用唯一索引兜底防止重复数据。
第三组:JWT和传统Session有什么区别?答:JWT无状态、可跨域、携带信息量可控,适合微服务和前后端分离架构;Session需要服务端存储,横向扩展时要做会话共享。同时承认JWT的不足——服务端无法强制失效,所以后续可以接入黑名单机制完善。
把这些问题的答案写成说辞背熟,比你紧张时临场发挥强一百倍。
6.2 项目可演示的加分项设计
除了核心预约流程,我还建议在项目里加一两个“看起来不复杂但很好展示”的功能,让演示更有层次感。
一个推荐的做法是给系统加公告/资讯模块,管理员在后台发布宠物体检提醒、疫苗接种通知,用户端首页轮播图加一个公告列表。这个功能实现起来就是一张表加两个接口,但演示时能展示“管理员端”和“用户端的信息流转”,评委一看就知道你考虑了真实业务场景。
另一个加分项是数据统计。后台统计每日预约量、宠物品种分布、热门科室排行,用几个聚合查询把数据算出来。虽然只是简单的GROUP BY,但视觉上很唬人,而且引出MyBatis-Plus自定义SQL的话题,让评委知道你不是只会用内置方法。
还有一点提醒:项目的README一定要写清楚怎么启动。别笑,很多同学生成项目的时候自己都忘了本地启动步骤,答辩演示现场无法运行的话,前面所有努力全部白费。把MySQL初始化脚本、后端启动步骤、小程序AppID替换、开发者工具配置域名校验开关,全部截图写进README,花二十分钟省答辩现场二小时的尴尬。
7. 写在最后
做了几年毕设指导,我发现最影响结果的因素其实不是技术难度,而是能不能“把会的东西清晰地表达出来”。宠物医院小程序这个题目的天花板完全可以支撑起一篇优秀毕设,做得好的人能顺带把微信生态的开发经验写进简历,面试时同样有得聊。
最后再分享一个我自己踩过的坑:整个项目开发过程中一定要定期用Git提交,哪怕每天只提交一行改动。我有一次连着写了两周的代码没有提交,电脑蓝屏后当场傻眼,重写花了整整三天。毕设周期长,不备份、不提交、不导出的教训,希望你们不用亲身经历一遍。
