ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java医院预约挂号系统微信小程序源码实战:Spring Boot+MyBatis-Plus防超卖与登录适配

Java医院预约挂号系统微信小程序源码实战:Spring Boot+MyBatis-Plus防超卖与登录适配 简介这是一套面向Java后端与微信小程序开发者的医院预约挂号系统完整源码采用SpringBoot MyBatisPlus MySQL Redis架构前台基于uni-app Vue构建微信小程序适合课程设计、毕业设计或二次开发练手。系统分前台、管理员后台与医生后台三端前台支持注册登录、预约挂号与核酸检测、查看预约与检测记录、医院公告、坐诊信息、就诊人管理及到院导航管理员可管理用户、排班、预约记录、科室、疾病、职位、医生、医院信息与公告医生端可设置排班并查看预约订单。资源包共185个文件以113个vue组件、41个js脚本、10个scss样式为主另含json配置、md说明与字体图标等压缩包约517KB结构清晰便于按模块阅读。已有2378人学习下载可帮助读者快速理解三端权限划分、预约排班业务链路与前后端接口组织方式。1. 从一份 Java 医院预约挂号系统源码说起微信小程序端到底解决了什么医院挂号这件事线下窗口排队的痛点已经被说了十几年但真正让中小医院和社区门诊愿意掏钱做数字化的往往不是在线支付这种大功能而是号源可视化 分时段预约 微信小程序免安装这三件事凑在一起。这份标题里的Java医院预约挂号系统 - 微信小程序.zip本质就是一套Spring Boot 后端 微信小程序前端的典型组合后端管科室、医生、排班、号源、订单小程序端负责登录、选科室、选医生、选时段、下单、查报告。它适合两类人——一类是接私活或做毕设、需要一套能跑起来的完整业务闭环的 Java 工程师另一类是想把微信小程序登录、手机号获取、顶部导航栏适配这些高频热搜点落到真实业务里的前端同学。下面我不谈系统多强大只谈这套东西怎么在你本地跑起来、参数怎么调、哪里最容易翻车。2. 后端骨架怎么搭Spring Boot MyBatis-Plus 的建表与分层2.1 为什么选 MyBatis-Plus 而不是纯 MyBatis 或 JPA医院挂号系统的表结构有个特点主表少、关联表多、查询条件碎。科室、医生、排班、号源、订单、患者、就诊人七张表起步每张表都要按状态、时间、科室 ID 做组合筛选。纯 MyBatis 要手写大量 XMLJPA 又容易在复杂联表时生成难控的 SQL。MyBatis-Plus 的定位刚好卡在中间单表 CRUD 用BaseMapper直接省掉复杂查询再写 XML 或Wrapper。热搜里有个词叫mybatisplus根据java实体类生成创建表的sql语句这其实是很多人搭骨架时最想省的一步。MyBatis-Plus 本身不直接生成 DDL但它的代码生成器AutoGenerator能根据表反向生成实体类反过来你要实体类 → 建表 SQL常见做法是用TableName、TableField注解配合一个自写的 DDL 工具类或者干脆用 Flyway/Liquibase 管理。我一般会先手写建表 SQL再用生成器反推实体这样字段类型不会跑偏。2.2 核心表结构与建表脚本先看最小可用的表设计。号源表是整个系统的核心它决定了一个医生某天某时段还剩几个号。-- 科室表 CREATE TABLE department ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(64) NOT NULL COMMENT 科室名称, parent_id BIGINT DEFAULT 0 COMMENT 父科室ID0为一级, sort INT DEFAULT 0 COMMENT 排序, status TINYINT DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生表 CREATE TABLE doctor ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT 医生姓名, department_id BIGINT NOT NULL COMMENT 所属科室, title VARCHAR(32) COMMENT 职称, intro VARCHAR(512) COMMENT 简介, avatar VARCHAR(255) COMMENT 头像URL, status TINYINT DEFAULT 1, PRIMARY KEY (id), KEY idx_dept (department_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排班表医生 日期 时段 CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT 出诊日期, period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL COMMENT 总号源, left_slots INT NOT NULL COMMENT 剩余号源, fee DECIMAL(10,2) DEFAULT 0 COMMENT 挂号费, PRIMARY KEY (id), UNIQUE KEY uk_doc_date_period (doctor_id,work_date,period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE appointment_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, patient_id BIGINT NOT NULL COMMENT 就诊人, schedule_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已预约 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明schedule表上的uk_doc_date_period唯一索引是关键它从数据库层面防止同一个医生同一天同一时段被插入两条排班避免号源重复。left_slots单独存而不是每次count订单是为了下单时能用一条UPDATE ... SET left_slots left_slots - 1 WHERE left_slots 0做原子扣减这是防超卖的核心。参数说明period用 TINYINT 而不是字符串省空间且索引效率高fee用 DECIMAL 不用 FLOAT金额计算不能有精度误差order_no加唯一索引防止前端重复提交生成两笔订单。2.3 号源扣减的原子操作与防超卖这是整个后端最容易翻车的地方。很多人第一版会写成先查 left_slots判断大于 0再 update 减一这在并发下必然超卖。// AppointmentServiceImpl.java Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long patientId, Long scheduleId) { // 原子扣减只有 left_slots 0 时才会更新成功 int affected scheduleMapper.decrementSlot(scheduleId); if (affected 0) { throw new BizException(该时段号源已约满); } // 扣减成功后再生成订单 AppointmentOrder order new AppointmentOrder(); order.setOrderNo(generateOrderNo()); order.setPatientId(patientId); order.setScheduleId(scheduleId); order.setStatus(0); orderMapper.insert(order); return convert(order); }!-- ScheduleMapper.xml -- update iddecrementSlot UPDATE schedule SET left_slots left_slots - 1 WHERE id #{scheduleId} AND left_slots gt; 0 /update逻辑说明把判断和扣减合并到一条 SQL 里靠数据库的行锁保证原子性。affected 0说明号源已满直接抛业务异常事务回滚。这样即使 100 个请求同时打进来也只有left_slots个能成功。参数说明Transactional的rollbackFor Exception.class必须显式写否则默认只回滚RuntimeException业务异常如果是受检异常会导致扣了号却没生成订单。generateOrderNo()建议用日期 雪花 ID或日期 Redis 自增不要用 UUID太长且无序。提示如果你的 MySQL 是 8.0 以上也可以考虑用SELECT ... FOR UPDATE显式加锁但性能不如上面的原子 UPDATE高并发场景优先选后者。3. 微信小程序端怎么接登录、手机号、导航栏三个高频坑3.1 微信登录换 openid 的完整链路小程序端登录不是输入账号密码而是wx.login拿 code后端拿 code 去换 openid 和 session_key。热搜里微信小程序登录获取手机号是问得最多的但登录和获取手机号是两步别混在一起。// pages/login/login.js Page({ onLogin() { wx.login({ success: (res) { if (!res.code) { wx.showToast({ title: 登录失败, icon: none }); return; } // 把 code 发给自己的后端 wx.request({ url: https://your-domain.com/api/wx/login, method: POST, data: { code: res.code }, success: (resp) { // 后端返回自定义登录态 token wx.setStorageSync(token, resp.data.token); wx.setStorageSync(openid, resp.data.openid); } }); } }); } });逻辑说明wx.login的 code 只能用一次五分钟内有效后端拿到后调用微信的code2Session接口换 openid。openid 绝对不能放在前端当身份凭证前端只存后端签发的 token每次请求带 token后端从 token 解析出 openid。参数说明wx.request的域名必须在小程序后台配置为合法域名且必须是 HTTPS。本地调试可以在开发者工具里勾选不校验合法域名但上线前一定要配好否则真机直接请求失败。3.2 获取手机号的正确姿势获取手机号用的是button open-typegetPhoneNumber不是wx.getPhoneNumber。用户点击后拿到加密的encryptedData和iv发给后端解密。// 获取手机号按钮 getPhoneNumber(e) { if (e.detail.errMsg ! getPhoneNumber:ok) { wx.showToast({ title: 您取消了授权, icon: none }); return; } wx.request({ url: https://your-domain.com/api/wx/phone, method: POST, header: { Authorization: wx.getStorageSync(token) }, data: { encryptedData: e.detail.encryptedData, iv: e.detail.iv }, success: (resp) { // 后端返回解密后的手机号 console.log(手机号, resp.data.phone); } }); }逻辑说明encryptedData用session_key做 AES 解密session_key是后端在code2Session时拿到的所以解密必须在后端做。前端拿不到明文手机号这是微信的安全设计。参数说明session_key会过期用户重新wx.login后会刷新。如果解密报-41003错误通常是session_key过期或encryptedData和iv不匹配让用户重新授权即可。3.3 顶部导航栏高度适配热搜里微信小程序顶部导航栏高度是个经典问题。小程序默认有原生导航栏但很多医院系统想要自定义导航栏比如放个科室搜索框这时候就要自己算状态栏高度。// app.js App({ onLaunch() { const sysInfo wx.getSystemInfoSync(); // 状态栏高度 const statusBarHeight sysInfo.statusBarHeight; // 胶囊按钮位置信息 const menuButton wx.getMenuButtonBoundingClientRect(); // 导航栏高度 胶囊底部 - 状态栏高度 胶囊顶部 - 状态栏高度 const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height; this.globalData.statusBarHeight statusBarHeight; this.globalData.navBarHeight navBarHeight; }, globalData: { statusBarHeight: 0, navBarHeight: 0 } });逻辑说明wx.getMenuButtonBoundingClientRect()拿到右上角胶囊按钮的top和height导航栏高度 胶囊上下间距之和 胶囊高度。这个算法在绝大多数机型上都能对齐。参数说明statusBarHeight在 iPhone 上是 44 或 47安卓机型差异大所以必须动态获取不能写死。自定义导航栏的页面要在app.json里把navigationStyle设为custom。注意wx.getSystemInfoSync在新版基础库中已被标记为不推荐可以换成wx.getWindowInfo和wx.getDeviceInfo但为了兼容老版本很多项目还在用同步版本。4. 排班与号源管理后台怎么配、前端怎么选4.1 排班生成的两种模式医院排班有两种常见模式固定周期排班比如某医生每周一三五上午出诊和手动逐日排班。前者适合医生出诊规律的三甲后者适合社区门诊。这套系统一般两种都支持后台管理端用固定周期批量生成再允许手动微调。// 批量生成一周排班 public void batchGenerate(Long doctorId, LocalDate startDate, ListInteger weekDays, Integer period, Integer totalSlots) { for (int i 0; i 7; i) { LocalDate date startDate.plusDays(i); // weekDays 里存的是 1-7 代表周一到周日 if (!weekDays.contains(date.getDayOfWeek().getValue())) { continue; } Schedule schedule new Schedule(); schedule.setDoctorId(doctorId); schedule.setWorkDate(date); schedule.setPeriod(period); schedule.setTotalSlots(totalSlots); schedule.setLeftSlots(totalSlots); // 唯一索引冲突时忽略避免重复生成 try { scheduleMapper.insert(schedule); } catch (DuplicateKeyException e) { // 已存在则跳过 } } }逻辑说明靠uk_doc_date_period唯一索引兜底重复生成时捕获DuplicateKeyException跳过不用先查再插省一次查询。参数说明weekDays用ListInteger存 1-7比存字符串好比较。totalSlots一般上午 20-30 个下午 15-20 个具体看医生。4.2 前端选号的时间轴交互小程序端选号一般用日期横向滚动 时段卡片的形式。日期滚动用scroll-view横向滚动时段卡片显示上午 剩余 8 号。// pages/doctor/detail.js Page({ data: { dateList: [], // 未来 7 天 currentDate: , scheduleList: [] // 当前日期的排班 }, onLoad(options) { this.doctorId options.doctorId; this.buildDateList(); this.loadSchedule(this.data.currentDate); }, buildDateList() { const list []; const weekMap [日, 一, 二, 三, 四, 五, 六]; for (let i 0; i 7; i) { const d new Date(); d.setDate(d.getDate() i); const dateStr ${d.getFullYear()}-${String(d.getMonth() 1).padStart(2, 0)}-${String(d.getDate()).padStart(2, 0)}; list.push({ date: dateStr, label: i 0 ? 今天 : 周${weekMap[d.getDay()]}, day: d.getDate() }); } this.setData({ dateList: list, currentDate: list[0].date }); }, loadSchedule(date) { wx.request({ url: https://your-domain.com/api/schedule?doctorId${this.doctorId}date${date}, success: (res) { this.setData({ scheduleList: res.data }); } }); } });逻辑说明日期列表在前端生成避免后端返回一堆日期数据。切换日期时重新请求该日排班left_slots为 0 的时段置灰不可点。参数说明日期格式统一用YYYY-MM-DD和后端DATE类型对应。padStart(2, 0)保证月份和日期补零否则2024-1-5这种格式后端解析会出问题。5. 避坑与排查这套系统上线前必须过的 5 道坎5.1 号源超卖现象是约满了还能下单现象压测时 100 个并发请求抢 10 个号结果生成了 15 笔订单。原因用了先查后减的非原子操作或者扣减 SQL 没加left_slots 0条件。解决改成UPDATE schedule SET left_slots left_slots - 1 WHERE id ? AND left_slots 0判断affected是否为 0。如果还超卖检查事务隔离级别和数据库引擎是不是 InnoDB。5.2 微信登录态失效现象是用户操作到一半突然要重新登录现象用户在小程序里选完号点提交提示登录已过期。原因后端 token 有效期设太短比如 30 分钟或者session_key过期后没做静默续期。解决token 有效期设 7 天前端在wx.request的fail回调里判断 401 状态码自动重新wx.login换 token 后重试原请求。别让用户手动点重新登录。5.3 手机号解密失败现象是解密报 -41003现象用户授权手机号后后端解密报错-41003。原因session_key过期或者前端传的encryptedData和iv不是同一次授权拿到的。解决确保wx.login和getPhoneNumber在同一个会话周期内session_key过期就让用户重新走一遍登录。另外检查后端 AES 解密用的session_key是不是最新的。5.4 排班日期时区错乱现象是今天约的号显示成昨天现象用户今天约的号订单里显示的就诊日期是昨天。原因服务器时区是 UTCLocalDate.now()拿到的是 UTC 日期和北京时间差 8 小时。解决JVM 启动参数加-Duser.timezoneAsia/Shanghai或者用LocalDate.now(ZoneId.of(Asia/Shanghai))。数据库连接串也加上serverTimezoneAsia/Shanghai。5.5 小程序包体积超限现象是上传提示 source size 超过 2MB现象uniapp 微信小程序打包后上传提示source size 2612kb exceed max limit 2mb。原因图片资源没压缩、引入了没用到的第三方库、或者uni_modules里塞了太多插件。解决图片全部走 CDN 不打包进代码用分包加载把非首屏页面拆出去manifest.json里开启optimization: { subPackages: true }。主包控制在 1.5MB 以内留余量。6. 进阶用 Redis 缓存号源 定时任务释放未支付订单6.1 为什么号源要进 Redis数据库扛得住挂号系统的日常流量但扛不住放号瞬间的洪峰。三甲医院早上 8 点放号几万人同时刷新全打到 MySQL 上left_slots那行会被锁到怀疑人生。常见做法是把号源余量同步一份到 Redis扣减先在 Redis 做异步落库。// 放号时预热到 Redis public void preloadScheduleToRedis(Long scheduleId) { Schedule schedule scheduleMapper.selectById(scheduleId); String key schedule:slot: scheduleId; redisTemplate.opsForValue().set(key, schedule.getLeftSlots(), 24, TimeUnit.HOURS); } // 下单时先扣 Redis public boolean tryDecrementInRedis(Long scheduleId) { String key schedule:slot: scheduleId; Long remain redisTemplate.opsForValue().decrement(key); if (remain null || remain 0) { // 扣成负数说明已满加回去 redisTemplate.opsForValue().increment(key); return false; } return true; }逻辑说明decrement是原子操作天然防并发。扣成负数说明超了立刻increment加回去。Redis 扣减成功后再异步发消息到 MQ 或直接更新数据库数据库那层仍然保留left_slots 0的兜底。参数说明Redis key 设 24 小时过期对应一天的号源周期。如果 Redis 挂了降级走数据库原子扣减保证系统可用。6.2 未支付订单的定时释放用户下单后 15 分钟不支付号源要释放回去否则号被占着别人约不了。// 每分钟扫描一次超时未支付订单 Scheduled(cron 0 * * * * ?) public void releaseTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListAppointmentOrder timeoutOrders orderMapper.selectList( new LambdaQueryWrapperAppointmentOrder() .eq(AppointmentOrder::getStatus, 0) .lt(AppointmentOrder::getCreateTime, deadline) ); for (AppointmentOrder order : timeoutOrders) { // 释放号源 scheduleMapper.incrementSlot(order.getScheduleId()); // 更新订单状态为已取消 order.setStatus(2); orderMapper.updateById(order); // 同步 Redis redisTemplate.opsForValue().increment(schedule:slot: order.getScheduleId()); } }逻辑说明Scheduled每分钟跑一次查 15 分钟前创建且状态为待支付的订单逐个释放。释放时数据库和 Redis 都要加回去否则两边数据不一致。参数说明cron 0 * * * * ?表示每分钟的第 0 秒执行。生产环境如果订单量大建议用延迟队列RabbitMQ 死信队列或 Redis ZSet替代轮询减少数据库压力。6.3 验证方法怎么确认这套逻辑真的没超卖别信我觉得没问题用 JMeter 或ab压测。准备 10 个号源起 200 个并发线程同时下单跑完后查数据库SELECT COUNT(*) FROM appointment_order WHERE schedule_id ? AND status IN (0,1)结果必须 ≤ 10。再查left_slots必须 ≥ 0。两个条件都满足才算过了防超卖这一关。我自己的习惯是每次改完扣减逻辑先跑一遍这个压测再提交代码——这个后悔药比上线后被运营追着问为什么号超了便宜太多。希望帮到你。本文还有配套的精品资源点击获取
返回列表