☰
微信小程序预约挂号系统毕设开发:数据库设计与并发控制全攻略
2026/10/13 18:52:44 网站建设 项目流程

简介:一份基于微信小程序的预约挂号系统毕业设计项目,前台采用微信开发者工具实现,后台基于 Java SSM 框架与 MySQL 数据库,覆盖管理员、医生、用户三类角色,包含用户管理、医生信息、排班、预约、取消预约与调班申请等完整业务流程,适合计算机相关专业课程设计、毕业设计及小程序开发学习者参考。压缩包共 1210 个文件,约 18.92MB,其中 png、jpg 为界面与素材图,java、xml、properties 为后端源码与配置,vue、js、json 为后台管理页面逻辑,wxml、wxss 为小程序页面结构;另含 sql 数据库脚本、bat 一键安装运行脚本及说明文档,便于本地快速部署。已有 77 人学习浏览,资源目录组织清晰,从后端接口到小程序端页面均有完整代码,可供二次开发或直接作为毕设演示项目,也可帮助理解 SSM 框架与小程序前后端配合的典型实现。

1. 从窗口排队到小程序秒级挂号:这个毕设为什么值得做

打开任何一家三甲医院的预约应用,高峰期里你能看到的只有加载转圈,患者要反复切换科室才能确认某个医生今天是否放号。预约挂号系统这个毕设选题,本质上是把一个典型业务场景搬进微信小程序——用户选科室、看医生、选时段、锁定号源、提交订单,一套流程闭环下来,既覆盖了完整的前后端交互,又自然带出了并发控制、事务一致性和微信生态适配这类评审老师爱问的点。这篇笔记按「需求建模 → 数据库设计 → 小程序端 → 后端并发 → 部署验收」的顺序,把基于微信小程序的预约挂号系统拆成可照做的完整路线。它适合正在开题或写代码卡壳的计算机专业学生,也适合想快速跑通医疗预约流程的开发者。

2. 预约挂号系统从需求到建表:先把号源模型立住

2.1 三个角色两套流程:把“预约”拆成可落地的业务规则

做预约挂号系统有个常见误区,上来就画页面写接口,结果做到一半发现医生出诊表要不要可配置、患者取消后号源能不能释放这类问题都没想清楚。我的经验是先把角色和流程钉死,后面的数据和代码才有主心骨。

系统可以按三个角色拆:患者通过小程序浏览科室和医生、选择就诊日期及时段、提交预约并支持取消;医生只需要看自己某个日期的预约列表,知道自己哪几个时段被占满(毕设里一般不给医生开放小程序,做管理后台即可);管理员负责维护科室、医生、出诊时段和号源总量,也承担查看订单统计、处理异常预约的职责。两套主流程分别是「预约挂号」和「取消预约」,它们共享同一个核心资源——号源(Schedule)。

设计业务规则时,有三个点最好提前定死,否则后面每改一处都要动表结构。第一,每个医生按日期和时段生成号源,比如某个医生上午 8:00-9:00 放 10 个号,而不是只存一个整天总量;第二,取消预约有时间限制,主流做法是就诊当天的号不允许线上取消,前一天 24 点前可取消;第三,患者在同一时段只能有一条有效预约,避免同一人占多个号。这三条规则直接决定了下文的表字段和状态机。

2.2 五张核心表的DDL:科室、医生、患者、号源、订单

预约挂号系统的数据库设计并不复杂,但要想让评审老师挑不出毛病,五张核心表的关系要清晰:科室一对多医生,号源落在医生维度,订单关联患者和号源。下面这组建表语句是我在同类项目里反复用的基础版本,字段做了精简,直接能跑在最常见的 MySQL 5.7/8.0 上。

-- 科室表 CREATE TABLE `t_department` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `code` VARCHAR(32) NOT NULL COMMENT '科室编码,如 surgery-01', `name` VARCHAR(64) NOT NULL COMMENT '科室名称', `intro` TEXT COMMENT '科室简介', `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序权重,越小越靠前', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_code` (`code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室表'; -- 医生表 CREATE TABLE `t_doctor` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `dept_id` BIGINT NOT NULL COMMENT '所属科室ID', `name` VARCHAR(32) NOT NULL COMMENT '医生姓名', `title` VARCHAR(32) COMMENT '职称:主任医师/副主任医师/主治医师', `avatar` VARCHAR(255) COMMENT '头像URL', `specialty` VARCHAR(255) COMMENT '擅长领域', `reg_fee` DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT '挂号费', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_dept` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生表';

继续看患者表、号源表和订单表,这三张的约束条件才是本项目的核心。患者表围绕微信 openid 建立唯一键,一个微信用户对应一条患者记录;号源表把「医生 + 出诊日期 + 时段」三个维度组成唯一键,并用total_count与used_count记录配额和已用量;订单表则通过唯一键把同患者同号源的有效预约挡在数据层之外。

-- 患者表(一个微信用户一条记录) CREATE TABLE `t_patient` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `nickname` VARCHAR(64) COMMENT '微信昵称', `real_name` VARCHAR(32) COMMENT '患者真实姓名', `phone` VARCHAR(20) COMMENT '手机号', `id_card` VARCHAR(32) COMMENT '身份证号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者表'; -- 号源表:医生在某个日期的某个时段放了多少号 CREATE TABLE `t_schedule` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `dept_id` BIGINT NOT NULL COMMENT '科室ID,冗余便于按科室查', `visit_date` DATE NOT NULL COMMENT '出诊日期', `start_time` VARCHAR(8) NOT NULL COMMENT '时段开始 HH:mm', `end_time` VARCHAR(8) NOT NULL COMMENT '时段结束', `total_count` INT NOT NULL DEFAULT 10 COMMENT '号源总数', `used_count` INT NOT NULL DEFAULT 0 COMMENT '已约数量', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0可约 1约满 2停诊', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_doctor_date` (`doctor_id`, `visit_date`, `start_time`), KEY `idx_date` (`visit_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='号源表'; -- 预约订单表 CREATE TABLE `t_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号', `patient_id` BIGINT NOT NULL COMMENT '患者ID', `schedule_id` BIGINT NOT NULL COMMENT '号源ID', `doctor_name` VARCHAR(32) COMMENT '冗余医生姓名', `dept_name` VARCHAR(64) COMMENT '冗余科室名', `visit_date` DATE NOT NULL COMMENT '就诊日期', `visit_time` VARCHAR(16) COMMENT '就诊时段,如08:00-09:00', `fee` DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT '挂号费', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0已预约 1已取消 2已完成 3已爽约', `cancel_reason` VARCHAR(255) COMMENT '取消原因', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `cancel_time` DATETIME COMMENT '取消时间', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_patient_schedule` (`patient_id`, `schedule_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';

字段和索引有几个地方需要说明。uk_patient_schedule这个唯一键本意是防止同一患者重复预约同一号源,但它有个副作用:患者取消预约后,这一对(patient_id, schedule_id)仍然存在,想要再次预约同号源会插入失败。因此实际开发中我一般会去掉这个唯一索引,改到 Service 层用条件判断,后面第 5 章会讲具体做法。version是为乐观锁预留的,用来防超卖,第 4 章会用到。订单表里冗余doctor_name和dept_name是刻意的,列表页和详情页不需要每次都连表查医生和科室,这是毕设里性价比很高的优化。

2.3 号源状态机与预约链路:可约、约满、取消如何流转

数据库表设计好,还需要把状态流转讲清楚。状态机是答辩时的高频问题,我可以直接给一套可用的定义。

号源状态只有三种:0 可约、1 约满、2 停诊。初始是0,当used_count = total_count时状态置为1;管理员维护排班时可手动把某时段置为2停诊。订单状态则复杂一些:0 已预约、1 已取消、2 已完成、3 已爽约。预约创建后状态为0;患者在规定时间内取消则进入1,同时号源的used_count减一,状态从约满回到可约;就诊日期过去且状态仍为0的,由定时任务批量更新为3 已爽约。

整条预约链路可以概括为一个受事务保护的动作序列:患者选号源 → 后端校验号源可约且患者无冲突订单 → 原子扣减号源used_count→ 插入订单表 → 返回订单号。这里有个顺序决策:先扣号源再插订单,可以保证即使后续插订单失败,事务回滚后号源恢复;如果反过来先插订单再扣号源,则有可能出现订单存在但号源没扣上。两条路都能靠事务兜底,但先扣号源更符合直觉,排查时也不容易出歧义。

3. 小程序端跑通预约链路:wx.login、列表加载与下单

3.1 用wx.login()换用户身份:新版登录接口的正确接法

小程序端的用户体系不同于网页,核心是微信提供的wx.login()获取临时code,后端拿这个 code 调用微信接口换取 openid 和 session_key。毕设里不需要做太复杂的账号密码体系,把 openid 作为患者表唯一键即可。

需要注意:老教程里常见的wx.getUserInfo()弹窗授权在基础库 2.27.1 之后已经被微信限制,主动弹窗拿不到昵称头像。现在的规范做法是用button的open-type="chooseAvatar"让用户主动选头像,用input type="nickname"填写昵称。所以登录页一般拆两步:先静默登录拿到 openid,再让用户自愿补充头像昵称。

// pages/login/login.js const app = getApp(); Page({ data: { requesting: false }, onLoad() { // 先看本地 token 还在不在,避免每次进小程序都走 wx.login const token = wx.getStorageSync('token'); if (token) { wx.switchTab({ url: '/pages/index/index' }); return; } }, handleLogin() { if (this.data.requesting) return; this.setData({ requesting: true }); wx.login({ success: async (res) => { if (!res.code) { wx.showToast({ title: '登录失败', icon: 'none' }); this.setData({ requesting: false }); return; } try { const result = await app.request('/api/login', { method: 'POST', data: { code: res.code } }); if (result.code === 0) { wx.setStorageSync('token', result.data.token); wx.switchTab({ url: '/pages/index/index' }); } else { wx.showToast({ title: result.msg, icon: 'none' }); } } finally { this.setData({ requesting: false }); } }, fail: () => { this.setData({ requesting: false }); wx.showToast({ title: '登录失败,请重试', icon: 'none' }); } }); } });

代码里有两个容易忽略的参数点。requesting标志位是为了防止用户连续点击登录按钮造成重复请求;wx.login失败时res.code不存在,必须提前return,否则后端会收到一个空的 code。app.request是封装好的 Promise 版wx.request,它要统一做三件事:拼接基础域名、往 header 里塞 token、把 HTTP 非 200 状态统一转成业务错误提示。

后端/api/login的职责是拿 code 换 openid,然后为该 openid 查找或创建患者记录,最后生成一个自定义 token 返回。实际的 code2Session 请求应当由后端发出,涉及appid和secret的代码绝不能写进小程序前端包,否则反编译后就会泄露密钥。

3.2 科室与医生列表:分页加载和顶部导航栏高度适配

预约首页一般展示科室卡片,点进科室后是医生列表,再点医生进入排班页。列表的常规做法是分页加载:onReachBottom触底时请求下一页,配合loading状态防止重复触发。

// pages/doctor/list.js Page({ data: { deptId: '', list: [], page: 1, pageSize: 10, hasMore: true, loading: false }, async onLoad(options) { this.setData({ deptId: options.deptId }); await this.loadList(1, true); }, // 触底加载下一页 async onReachBottom() { if (!this.data.hasMore || this.data.loading) return; await this.loadList(this.data.page + 1, false); }, async onPullDownRefresh() { await this.loadList(1, true); wx.stopPullDownRefresh(); }, async loadList(page, reset) { this.setData({ loading: true }); try { const res = await app.request('/api/doctor/list', { data: { deptId: this.data.deptId, page, pageSize: this.data.pageSize } }); const rows = res.data.list || []; this.setData({ list: reset ? rows : this.data.list.concat(rows), page, hasMore: page * this.data.pageSize < res.data.total }); } finally { this.setData({ loading: false }); } } });

这里的关键参数是hasMore的计算:用page * pageSize < total判断是否还有下一页。新手常犯的错误是在onReachBottom里累加page后再请求,结果快速滚动时连续触发多次,出现列表数据重复。配合loading标志位可以挡住绝大部分重复请求。

页面顶部导航栏的高度适配也是列表页常见的坑。自定义导航栏时,wx.getWindowInfo()拿到的statusBarHeight是状态栏高度,胶囊按钮的位置要用wx.getMenuButtonBoundingClientRect()获取,两者相加才是导航栏实际高度。如果直接写死 44px,在 iPhone 和 Android 真机上会出现标题偏上或偏下,视觉上非常明显。毕设阶段更推荐用默认导航栏,省掉这套适配,但答辩时若能说清这两个 API 的差异,能加分不少。

3.3 预约下单页:日期选择、号源锁定与防止二次提交

预约下单页是整个小程序端最核心的交互。患者选择就诊日期后,小程序向后端请求该日期下该医生的号源列表;用户点选某个时段,再点「确认预约」提交。这里需要在前端做好两件事:日期范围限制(只能选从今天起未来 7~14 天)、提交按钮的防重复点击。

// pages/booking/confirm.js const app = getApp(); Page({ data: { doctorId: '', doctor: null, dates: [], selectedDate: '', scheduleList: [], selectedScheduleId: null, submitting: false }, onLoad(options) { this.setData({ doctorId: options.doctorId, dates: this.buildDates(7) }); }, // 生成未来7天的可选日期 buildDates(days) { const list = []; const now = new Date(); for (let i = 0; i < days; i++) { const d = new Date(now.getTime() + i * 86400 * 1000); const month = d.getMonth() + 1; const day = d.getDate(); list.push({ label: `${month}月${day}日`, value: `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` }); } return list; }, async chooseDate(e) { const date = e.currentTarget.dataset.date; this.setData({ selectedDate: date, selectedScheduleId: null }); const res = await app.request('/api/schedule/list', { data: { doctorId: this.data.doctorId, date } }); const schedules = (res.data || []).map(s => ({ ...s, displayTime: `${s.startTime} ~ ${s.endTime}`, remaining: s.totalCount - s.usedCount })); this.setData({ scheduleList: schedules }); }, chooseSchedule(e) { this.setData({ selectedScheduleId: e.currentTarget.dataset.id }); }, async submitOrder() { if (this.data.submitting) return; const { selectedScheduleId, doctorId } = this.data; if (!selectedScheduleId) { wx.showToast({ title: '请先选择就诊时段', icon: 'none' }); return; } this.setData({ submitting: true }); try { const res = await app.request('/api/order/create', { method: 'POST', data: { doctorId, scheduleId: selectedScheduleId } }); if (res.code === 0) { wx.redirectTo({ url: `/pages/order/detail?id=${res.data.orderId}` }); } else { wx.showToast({ title: res.msg, icon: 'none' }); // 预约失败,刷新当前号源供用户重选 this.chooseDate({ currentTarget: { dataset: { date: this.data.selectedDate } } }); } } finally { this.setData({ submitting: false }); } } });

submitting的防重复逻辑要放在请求之前判断,且在finally中复位,这样即使接口报错也能恢复点击。号源剩余量remaining在前端计算展示,但提交时不要传remaining,后端只认scheduleId,最终余量必须以后端为准,否则趁手改成前端的数字就毫无意义。

另外,页面离开时需要清理状态。如果用户在预约中途切走,再次回来自动刷新号源列表,避免看到已过期的余量。这个动作放在onShow里触发比较稳妥,比在onLoad里只请求一次更符合真实场景。

4. 后端接口与并发控制:号源扣减怎样做到不超卖

4.1 预约业务最少需要哪六个接口

小程序端页面跑通之后,后端接口的职责就很清晰了。整套系统最少需要六个接口支撑核心流程,其他统计、管理功能可以绕后补。

接口方法入参出参说明
/api/loginPOST{ code }{ token, userInfo }登录换 token
/api/department/listGET无[{ id, name, intro }]科室列表
/api/doctor/listGETdeptId, page, pageSize{ total, list }分页医生列表
/api/schedule/listGETdoctorId, date[{ id, startTime, endTime, totalCount, usedCount }]某医生某天的时段列表
/api/order/createPOST{ scheduleId }{ orderId, orderNo }创建预约
/api/order/cancelPOST{ orderId }无取消预约

这六个接口里,前四个都是查表操作,压力不大;真正考验设计的是后两个写接口。/api/order/create要同时完成校验、扣减号源、创建订单三步;/api/order/cancel则要完成状态校验、更改订单、释放号源三步。两者都必须放在数据库事务里执行,并且要处理并发场景。

接口路径和参数名建议与微信小程序端保持一致。这个app.request封装会默认带上 token,后端的拦截器对除/api/login之外的接口统一校验身份,这样每个业务接口里不需要重复解析患者身份,直接从当前登录上下文里取patientId即可。

4.2 先查再插的危险与原子扣减:用UPDATE控制号源

预约系统最容易翻车的地方是并发超卖。假设某个号源总量是 10,当前used_count是 9,此时两个患者同时发起预约请求。如果代码写成「先查 used_count,小于 total_count 就插入订单」,两个请求都会读到 9,然后各自把used_count + 1,数据库里最终结果是 11,超卖一个号。

正确思路是把扣减这一步做成原子的条件更新。MySQL 的UPDATE语句本身是行级原子操作,只要把判断条件写进WHERE子句,就能挡掉超卖:

UPDATE `t_schedule` SET `used_count` = `used_count` + 1, `version` = `version` + 1 WHERE `id` = #{scheduleId} AND `used_count` < `total_count`;

执行完这条语句后,通过 JDBC 的int rows = update(...)判断受影响行数。rows == 1说明扣减成功,可以继续插订单;rows == 0说明号源已经被抢完,直接抛出业务异常。这一步利用的是数据库行锁,两个并发请求同时对同一行做条件更新时,后一个会等前一个提交后才执行,此时used_count已经变大,条件不满足,自然失败。

在此基础上,Service 层还需要处理「同一患者重复预约同一时段」的校验。可以这样组织事务方法:

@Service public class OrderService { @Resource private ScheduleMapper scheduleMapper; @Resource private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public OrderCreateResult create(Long patientId, Long scheduleId) { // 1. 检查患者是否已有同日期有效预约 ScheduleEntity schedule = scheduleMapper.selectById(scheduleId); if (schedule == null) { throw new BizException("号源不存在"); } if (schedule.getStatus() == 1 || schedule.getStatus() == 2) { throw new BizException("该时段不可预约"); } OrderEntity exists = orderMapper.selectActiveByPatientAndDate(patientId, schedule.getVisitDate()); if (exists != null) { throw new BizException("您已预约该日期的时段"); } // 2. 原子扣减号源 int rows = scheduleMapper.decreaseCount(scheduleId); if (rows == 0) { throw new BizException("号源已被约完,请更换时段"); } // 3. 创建订单 OrderEntity order = new OrderEntity(); order.setOrderNo(generateOrderNo()); order.setPatientId(patientId); order.setScheduleId(scheduleId); order.setDoctorName(schedule.getDoctorName()); order.setDeptName(schedule.getDeptName()); order.setVisitDate(schedule.getVisitDate()); order.setVisitTime(schedule.getStartTime() + "-" + schedule.getEndTime()); order.setFee(schedule.getRegFee()); order.setStatus(0); orderMapper.insert(order); return new OrderCreateResult(order.getId(), order.getOrderNo()); } }

注意事务方法的粒度:校验、扣减、插入必须在同一个事务内。decreaseCount对应上面那条条件 UPDATE,它的 Mapper XML 里除了WHERE id = ? AND used_count < total_count,还可以加上AND status = 0,把停诊的号源也挡在外面。异常必须抛运行时异常,事务才能回滚,如果捕获后吞掉,就会出现「订单没插进去但号源被扣掉」的数据不一致。

version字段在条件更新里虽然存在但没用到,这是因为used_count < total_count已经具备并发的判断能力。保留version是给答辩时讲乐观锁思路用的:如果评审老师问「怎么防并发超卖」,你可以说条件更新是数据库行的原子自增;也可以再加一句,更严密的版本号方案是把version = #{oldVersion}写进 WHERE,更新失败则重试。两者选一个讲透即可,不必都实现。

4.3 取消预约与号源释放:事务里的行锁与回滚边界

取消预约比创建容易踩坑,因为它涉及两个表的写操作:订单状态改成已取消,号源used_count减一。如果这两个操作不在事务里,一旦释放号源失败,患者取消了但号源没有恢复,其他患者就少一个号。

创建预约时不需要显式加行锁,因为条件更新本身锁住了那一行;但取消预约必须先读取订单状态再更新,这里建议用悲观锁防止并发取消和并发改状态打架。示例代码如下:

@Transactional(rollbackFor = Exception.class) public void cancel(Long patientId, Long orderId) { // 1. 行锁锁定订单,防止并发操作同一条订单 OrderEntity order = orderMapper.selectByIdForUpdate(orderId); if (order == null || !order.getPatientId().equals(patientId)) { throw new BizException("订单不存在"); } if (order.getStatus() != 0) { throw new BizException("当前状态不可取消"); } LocalDate visitDate = order.getVisitDate().toLocalDate(); LocalDate today = LocalDate.now(); if (visitDate.isBefore(today.plusDays(1))) { throw new BizException("就诊前一天起不可线上取消"); } // 2. 更新订单状态为已取消 orderMapper.updateStatus(orderId, 1, LocalDateTime.now()); // 3. 释放号源 scheduleMapper.releaseCount(order.getScheduleId()); }
-- 释放号源,注意用 GREATEST 防止 used_count 被减成负数 UPDATE `t_schedule` SET `used_count` = GREATEST(`used_count` - 1, 0) WHERE `id` = #{scheduleId};

selectByIdForUpdate是SELECT ... FOR UPDATE的 Mapper 封装,它会把订单行锁住直到事务提交。锁的目的是让相同的订单在同一时间只有一个取消请求进去,后面的请求等锁释放后,读到状态已经是 1,直接抛出不可取消。GREATEST是为了防止由于数据异常导致used_count减成负数,属于防御性写法。

这个事务的回滚边界值得多写一句:如果第 3 步释放失败抛出异常,前两步也会自动回滚,订单保持已预约状态,号源未释放,数据保持一致。所以千万不要在 Service 里 try-catch 后把异常吞掉,一定要让事务感知到异常。毕设评审时,这个细节比接口本身更容易被追问。

5. 预约挂号系统常见问题与避坑指南

5.1 真机环境下所有请求失败:合法域名与HTTPS没配

现象:小程序在微信开发者工具里一切正常,一扫码在真机上打开,所有接口都报request:fail url not in domain list。

原因:开发工具默认勾选了「不校验合法域名」,接口随便个http://localhost:8080都能通;真机上微信强制要求请求地址是 HTTPS 且在后台配置过合法域名。

解决:开发阶段,在开发者工具「详情 → 本地设置」勾选「不校验合法域名」,同时把真机调试的「不校验域名」打开,可以先用http://192.168.x.x:8080联调;提审前,去微信公众平台配置 request 合法域名,并给服务器配上 HTTPS 证书。这条几乎是每个小程序毕设的必经之坑,提前在文档里写清楚,评审演示时才不会白屏。

5.2 快速双击提交同一时段:幂等控制缺位

现象:演示时快速点两次「确认预约」,生成了两条相同的预约记录,号源被扣两次。

原因:前端明明加了submitting拦截,但真实场景下可能有多个页面入口、接口重试、或网络延迟导致第一次请求还没返回用户又点一次。前端的标志位只能挡正常用户,挡不住并发请求。

解决:分两层处理。前端保留submitting拦截,只作为体验优化;后端在创建预约的 Service 里做「同患者同日期已有有效预约」的校验,也就是第 4 章里selectActiveByPatientAndDate的存在性检查。另外可以给创建接口加一个幂等键参数requestId,订单表增加request_id唯一索引,重复请求直接插入失败,这是最彻底的方案。毕设里做到「后端存在性校验」就够了,幂等键可作为扩展加分项。

5.3 并发压测时号源超卖:条件更新用错或漏掉

现象:用 JMeter 并发 100 个请求打某个号源的创建接口,预约成功数超过了号源总数。

原因:最常见的两种——一是写成了先查后插,没做原子扣减;二是条件更新 SQL 写错了,比如WHERE used_count <= total_count或者没带AND used_count < total_count。前者并发读旧值,后者在高并发下边界条件没挡严。

解决:统一使用第 4.2 节的条件更新,并在 Mapper 里写死WHERE id = ? AND used_count < total_count。压测前可以先用脚本造一个 total_count=10 的号源,开 20 个线程同时打,断言成功数不超过 10。跑通了这条,答辩讲并发控制就稳了。

5.4 预览二维码别人打不开:体验成员与真机调试

现象:点工具栏「预览」生成的二维码发给同学,对方扫码提示「无法显示页面」或白屏。

原因:预览二维码只对项目成员和体验成员有效。微信小程序如果没有把所有协作者加进体验成员名单,非体验用户扫码就看不到。

解决:在微信公众平台后台「成员管理 → 体验成员」里添加对方微信号。开发阶段更推荐用「真机调试 2.0」,它生成的二维码只需要对方是微信好友就能打开,不需要配体验成员,迭代也更快。这里要提醒一句:体验版能打开不代表正式版能通过审核,真正提交需要用具备合法域名的线上接口。

5.5 订阅消息只能发一次:一次性授权与模板设计

现象:预约成功后想要给患者发一条「预约成功通知」,再发一条「就诊提醒」,发现第二次发送失败,用户收不到消息。

原因:微信的订阅消息是「一次性订阅」,用户点一次授权按钮,只能支撑一次下发。如果让用户在一个页面点了两个不同模板的授权,每个模板各自获得一次下发机会,但次数并不累加。

解决:设计模板时把两条消息拆开。预约成功页面弹出wx.requestSubscribeMessage,让用户同时勾选「预约成功通知」和「就诊提醒」两个模板,后端分别记录两个模板的授权状态;每次真正下发一条后,该模板的可用次数就消耗掉。如果用户未授权就诊提醒,后端可以改在就诊前一天再弹一次授权请求,但要克制,弹窗太频繁会被微信侧限制。毕设阶段可以只保留预约成功通知一条,减少交互复杂度,然后把「就诊提醒」作为扩展点写进论文。

6. 上线部署与答辩优化:三个动作让毕设从“能跑”到“能验收”

6.1 小程序上线三件套:备案域名、HTTPS证书、合法域名配置

拿到一个能跑的本地项目到可以给评审老师演示、甚至发布体验版,中间还有三件套要补齐:已备案的域名、HTTPS 证书、小程序后台的合法域名配置。购买云服务器后,把后端 jar 包部署上,再用 Nginx 做反向代理即可:

server { listen 443 ssl; server_name api.yourdomain.com; ssl_certificate /etc/nginx/cert/fullchain.pem; ssl_certificate_key /etc/nginx/cert/privkey.pem; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

确认域名证书生效后,到微信公众平台「开发管理 → 开发设置 → 服务器域名」加入https://api.yourdomain.com。配置生效有几分钟延迟,改完别急着测。整个环节最耗时的是 ICP 备案,正常需要一周左右,预留时间要充足,这也是开发工具里能跑和交付体验版之间的真正差距。

6.2 用Redis扛高并发:号源扣减升级思路

MySQL 条件更新能扛住毕设演示和百级并发压测,但如果想在答辩时讲出层次,可以提 Redis 扣减号源的方案:把号源余量预热到 Redis,用DECR原子自减,扣到负数则拒绝预约。下单成功后异步把订单写进数据库,Redis 和数据库之间通过定时对账保证一致。这里要注意,Redis 方案虽然并发能力更强,却引入了缓存与数据库不一致的复杂度,毕设中如果不做对账脚本,不建议主推。把它作为「后续优化方向」放在论文结尾,比强行实现一个半吊子方案更稳妥。

6.3 验收演示脚本与数据准备

答辩演示最怕现场临时找数据。提前在数据库里准备好两个科室、三位医生、未来三天每个时段 5~10 个号源,并准备一个已取消和一个已完成的订单做状态展示。演示动线按「登录 → 选科 → 选医 → 选时段 → 提交预约 → 查看订单 → 取消预约 → 再约」走一遍,中途故意演示一次「号源已满」的拦截,体现后端校验的存在感。

我是第一次做类似选题时,把精力全放在写接口上,结果答辩前才发现订阅消息发不出去、预览二维码别人扫不开,连着两个晚上都在补配置。现在做成方案我再不会漏掉这三件事:登录接口和域名配置最先验证,真机跑通再开发新功能;一切前端展示的号源余量都只当参考,以后端返回为准;每一张状态表的变化都要在数据库里能回溯到时间点。希望这些从需求建模到部署验证的细节,能帮你在这个毕设上少走几段弯路。祝一次过稿,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询