ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

uniapp与SSM构建医疗挂号系统:从数据库设计到号源并发控制

2026/9/12 14:51:28 拓冰建站 浏览量
uniapp与SSM构建医疗挂号系统:从数据库设计到号源并发控制 1. 一个挂号系统背后的核心业务流程与技术选型逻辑医疗挂号这件事表面上看就是选科室、选医生、选时间、提交预约但真正落地成一个可运行的线上服务系统牵扯的东西比我最初预想的多得多。我做完这套uniappvue小程序SSM医疗挂号就诊服务系统之后最深的感受是挂号只是入口后面的号源管理、排班规则、医生与科室的多级联动、就诊状态的流转才是系统真正复杂的地方。先说说为什么前端选了uniapp。如果只做微信小程序用原生小程序开发其实完全够用但现实需求往往没这么单纯——医院可能同时需要支付宝小程序、抖音小程序甚至是H5版本嵌入公众号。uniapp的好处是一套Vue语法的代码可以编译到多个端虽然做不到绝对零成本迁移但核心业务逻辑和页面结构大部分能复用。我在项目里实际上只编译了微信小程序端和H5端但这段经历让我后来接其他跨端需求时少走很多弯路。前端用Vue语法开发对团队里熟悉Vue的开发者来说上手门槛也低招人比找原生小程序开发容易。而后端之所以选SSMSpring SpringMVC MyBatis很多人会质疑现在不都Spring Boot了还折腾SSM干什么这里有一个很现实的原因——很多医疗信息化的老系统就是SSM架构尤其是一些二甲、三乙医院的内部系统后端的升级替换周期非常长。如果你未来要对接医院的HIS医院信息系统对方给你的接口文档大概率还是老一套基于Servlet规范的XML配置项目。SSM能让你理解Spring MVC的请求处理链路和MyBatis的SQL映射底层逻辑这套能力迁移到Spring Boot只是换个壳的事。另外作为教学、毕业设计或中小型外包项目的技术方案SSM结构清晰、分层明确面试时也容易讲清楚。这个系统的功能边界我建议控制在六个核心模块用户登录注册、科室与医生信息浏览、医生排班查询、在线预约挂号、就诊状态跟踪、个人中心订单与病历记录。如果要做完整闭环还可以加支付和退号但我通常会把支付设计成到院支付因为微信支付涉及商户号资质不是每个真实开发环境都能立刻申请下来。先把挂号主流程跑通支付模块作为可扩展接口预留这是比较务实的做法。这套技术组合的核心价值在于前端用uniapp解决多端覆盖问题后端用SSM提供稳定清晰的业务接口两边通过RESTful JSON交互整个系统拆开来每一层都是常规技术合在一起就是一个完整的医疗挂号业务闭环。2. 数据库建模科室、医生、排班、号源之间的关联设计数据库是整个挂号系统的地基这块如果设计得有缺陷后面写多复杂的接口都救不回来。我第一版设计踩过一个坑把医生排班直接设计成一张大表字段堆了二十多个写到后面自己都分不清哪个字段属于排班、哪个属于号源后来推倒重来才理顺。2.1 六张核心业务表怎么设计我用的是MySQL 5.7字符集统一utf8mb4因为要存患者姓名、备注等信息避免生僻字出问题。核心表我拆成了六张用户表t_user字段包括id、openid微信登录标识、phone、user_name、id_card、gender、create_time。openid是微信小程序登录后拿到的唯一标识用它关联本地用户记录这样用户不需要单独注册账号。id_card字段建议加密存储我实际用的是AES对称加密密钥放服务端配置文件里不要明文存。科室表t_department字段包括id、dept_name、dept_code、level一级科室还是二级科室、parent_id、intro、sort_order、status。科室是层级结构比如内科是父科室心血管内科是子科室。前端展示时通常只展示二级科室作为挂号入口一级科室作为分组标题。parent_id自关联的设计可以灵活支持两层甚至三层科室树。医生表t_doctor字段包括id、doctor_name、dept_id、title职称如主任医师/副主任医师、avatar、intro、good_at擅长领域、is_delete。医生表和科室表是多对一关系一个医生属于一个科室一个科室有多个医生。这里我用了逻辑删除is_delete而不是物理删除因为医生可能已有历史挂号记录贸然删除会破坏关联数据。排班表t_schedule字段包括id、doctor_id、schedule_date、period上午/下午/晚班、total_count总号数、left_count剩余号数、status、create_time。排班表是核心的号源池每个医生每天每个时段一行记录。total_count由医院排班规则决定比如普通号每天60个专家号每天30个left_count在预约成功时递减。挂号记录表t_registration字段包括id、user_id、doctor_id、schedule_id、reg_date、period、order_no订单号、status待就诊/已完成/已取消/已退号、create_time、cancel_time、remark。就诊记录表t_treatment可选 字段包括id、reg_id、diagnosis诊断结果、prescription处方信息、doctor_advice医嘱、create_time。这套表的关联关系走下来非常顺用户选择科室科室下挂医生医生有排班排班对应号源用户预约排班生成挂号记录就诊结束后生成就诊记录。一条链路清晰可查。2.2 号源扣减的并发控制乐观锁怎么设这是整个系统技术含量最高的一个点。假设一个专家号只有30个号30个用户同时发起预约请求如果代码写成// 错误示例 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule.getLeftCount() 0) { schedule.setLeftCount(schedule.getLeftCount() - 1); scheduleMapper.updateById(schedule); // 生成挂号记录 }这一定出问题。两个请求同时读到left_count1都判断大于0都执行减一最终left_count变成0但两个用户都预约成功了超卖。SSM框架下最简洁的解决方案是乐观锁加条件更新。在排班表里加一个version字段或者直接用left_count作为校验条件更新SQL写成update iddecreaseLeftCount UPDATE t_schedule SET left_count left_count - 1 WHERE id #{scheduleId} AND left_count 0 /update用MyBatis执行这个SQL然后检查影响行数。如果返回1表示扣号成功返回0表示号源已经被抢完业务层直接抛出号源不足的异常。这种方法比悲观锁性能好得多也避免了对表加锁造成的性能瓶颈。我之前压测过500并发抢30个号最终只会成功30个请求剩余全部被正确拒绝。2.3 排班生成与号源池的初始化策略排班数据不是用户主动产生的而是管理员后台操作生成的。我设计了一个排班管理的接口支持两种方式单日单医生排班和批量周排班。批量生成时根据医生所在科室的号源配置规则一个医生一周7天可以分别设置哪些天出诊、上午下午各放多少号。批量生成的SQL用MyBatis的foreach标签循环插入。这里有一个细节要提醒号源池初始化之后left_count不要通过update语句去改而是每次预约扣减时用条件更新。我初期犯过错误用程序读出来再写回去导致并发下数据不一致。改成条件更新后这个问题彻底消失。3. 后端SSM接口层从登录鉴权到预约挂号的接口设计SSM的后端接口设计遵循经典三层架构Controller接收请求、Service处理业务逻辑、Mapper操作数据库。我按业务模块划分了包结构每个模块的接口都遵循统一的返回格式这样前端解析数据时不需要分情况处理。3.1 统一返回体与异常处理前后端分离模式下接口返回格式必须固定。我定义的统一返回体是public class ResultT { private Integer code; // 200成功500业务异常401未登录 private String message; // 提示信息 private T data; // 业务数据 }Controller层所有接口都返回Result对象业务异常通过自定义异常捕获后返回统一错误信息。这样前端每次请求只需要先判断code能大幅减少重复代码。我曾经见过不少项目每个接口返回的字段名都不一样联调时前端写一堆if判断纯粹是给自己挖坑。异常处理使用Spring的ControllerAdvice做全局异常拦截业务异常BusinessException捕获后返回code500系统异常返回code500并记录日志未登录返回code401并提示重新登录。这套机制在SSM里配置非常成熟网上资料很多直接用就行。3.2 微信登录与本地账号体系的绑定微信小程序登录流程是这样的前端调用wx.login拿到临时code传给后端接口后端用code调用微信的code2Session接口换取openid和session_key再拿openid去t_user表查询用户是否存在。流程不算复杂但有三个坑要提醒第一code有效时间是5分钟且只能用一次所以前端必须在用户点击登录时实时获取不能缓存。我遇到过一次把code存在全局变量里导致过期的问题排查了很久。第二openid是微信用户的唯一标识不是用户手机号。如果系统需要手机号得用微信的获取手机号接口或者让用户手动输入。我在项目里做了一个手机号绑定页面让用户在首次登录时输入手机号系统判断该手机号是否已注册如果已注册则做账号合并否则直接绑定。第三登录成功后服务端生成一个token返回给前端后续所有请求在请求头里带token服务端通过拦截器校验。token我用的是简单的UUIDRedis存储设置了30天过期因为医疗项目的用户使用频率不算高太短会导致频繁登录体验很差。为了提高安全性token存储时记录了绑定用户id校验时解析出来放到ThreadLocal里Service层可以直接拿到当前用户不用每个接口都传userId参数。3.3 挂号接口的幂等处理挂号接口是整个系统最重要的接口必须保证幂等——同一个用户同一个排班只能挂一次号重复请求不能生成重复记录。实现方式在t_registration表上给user_id和schedule_id建唯一索引插入时捕获DuplicateKeyException异常捕获到就返回您已挂过该号源。这是数据库层面的兜底方案比应用层判断更可靠。同时业务层也要先查一次是否存在记录提前给出友好提示避免每次都走到数据库唯一索引报错。挂号接口的事务配置也要注意扣减号源left_count、插入挂号记录、更新用户就诊记录这三个操作必须在同一个事务里。Spring的事务注解Transactional默认只对RuntimeException回滚如果自定义的检查异常也要回滚需要显式指定rollbackFor。4. uniapp前端结构与关键页面实现uniapp前端的整体结构我按业务模块划分pages目录下放页面components目录放可复用组件api目录统一管理接口请求utils目录放工具函数和公共方法。页面之间的跳转使用uniapp封装的uni.navigateTo和uni.switchTab底部导航用tabBar实现包含首页、科室、预约记录、我的四个主入口。4.1 首页与科室列表数据渲染和骨架屏处理首页是一个信息聚合页顶部是医院横幅中间是快捷入口选择科室、预约查询、就诊指南下面是热门科室推荐。这些数据都通过接口异步加载为了避免用户看到白屏我用了一个简单的骨架屏组件请求未返回时展示灰色占位块请求完成后替换为真实内容。骨架屏的实现原理很简单用v-if判断数据是否加载完成即可。科室列表页的交互是展开-收起的折叠面板一级科室可点击展开展开后显示下属的二级科室点击二级科室进入医生列表。数据结构和后端t_department表的层级设计完全对应。这里有一个适配技巧展开面板动画在微信小程序端和H5端的表现不一致小程序里使用v-show切换有轻微卡顿换成CSS的max-height过渡动画后流畅很多。医生列表页展示医生头像、职称、擅长领域和预约按钮点击进入医生详情页。医生详情页除了展示信息外核心是排班日历日历上一周7天每天显示上午、下午两个时段的剩余号数点击某一天的某个时段发起预约。4.2 预约页的日期选择和号源状态联动预约页是用户操作密度最高的页面交互设计直接影响用户体验和成单率。日期选择器我用uniapp内置的picker组件modedate可以选择未来14天的日期。但这里要注意一个vue的响应式陷阱picker选中的值默认是字符串赋值给data后页面要能实时刷新必须用this.$set或确保属性已提前声明。我见过不少新手在data里没预先声明scheduleList接口返回后页面不渲染就是这个原因。选完日期后前端根据doctorId和scheduleDate调用排班查询接口返回当天上午、下午、晚班的号源情况。UI上我用三个卡片分别展示三个时段每个卡片显示剩余号数和预约按钮剩余为0时按钮置灰不可点。这里的核心交互逻辑是切换日期时清空之前选中的时段避免用户误以为选了日期A的号源实际提交的是日期B的号源。确认预约前弹出一个确认弹窗展示医生、日期、时段、就诊人姓名、挂号费用户确认后才提交。这只是用户体验层面的确认真正的防重复提交还要靠后端幂等设计。4.3 预约记录与就诊状态流转预约记录列表是用户管理号源的入口展示历史预约每条记录显示医生信息、预约时间、当前状态。状态我用不同颜色标签区分待就诊蓝色、已完成灰色、已取消橙色。就诊状态流转的整体链路是用户提交预约后状态为待就诊 - 用户到院就诊医生在后台标记为已完成 - 用户取消预约状态变为已取消。这里有一个业务细节取消预约后需要把排班的left_count加回去但只有待就诊状态才能取消已完成和已取消的状态不能再次操作。这个约束在后端的Service层和前端页面都要做判断前端通过按钮显隐控制操作入口后端通过状态校验兜底。4.4 网络层封装与token注入uniapp的网络请求我封装在utils/request.js里核心逻辑是对uni.request做Promise包装统一拼接baseURL、统一注入Authorization请求头、统一拦截401未登录状态。// 封装示例简化版 export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Authorization: uni.getStorageSync(token) || }, success: (res) { if (res.data.code 200) { resolve(res.data.data); } else if (res.data.code 401) { uni.navigateTo({ url: /pages/login/login }); reject(res.data); } else { uni.showToast({ title: res.data.message, icon: none }); reject(res.data); } }, fail: (err) { uni.showToast({ title: 网络请求失败, icon: none }); reject(err); } }); }); }这样封装之后页面里调用只需要关心业务数据和错误提示不需要每个请求都处理token和统一错误逻辑。这个工具函数是我整个前端项目里复用率最高的代码强烈建议所有uniapp项目都做一层这样的封装。5. 前后端联调中必踩的坑与排查链路这一部分是我最想写的内容。前后端分离开发联调阶段一定会遇到各种奇怪的问题很多问题表面上是代码bug实际上是对两端机制理解不一致导致的。我把这次项目联调中遇到的问题完整复盘出来。5.1 微信小程序不显示后端图片域名白名单的完整排查路径第二个问题是图片资源跨域失效。小程序页面上医生头像一直显示不出来但H5端访问同样的图片地址是正常的。排查过程我走了三步第一步确认图片地址是否正确。在浏览器直接访问该地址发现能正常显示说明后端静态资源服务没问题。第二步检查请求是否被拦截。打开微信开发者工具的Network面板发现图片请求直接失败提示是不在以下 request 合法域名列表中这里需要区分开发者工具里勾选了不校验合法域名才能正常请求但这只在开发模式生效。第三步真正的原因是微信小程序的所有网络请求包括图片都要求域名备案且配置到小程序后台的合法域名白名单中。开发环境可以通过勾选不校验合法域名绕过但真机预览和上线时必须配置合法域名。如果实际使用的是IP地址加端口微信会直接拒绝换成已备案的域名并配置SSL证书才能解决。5.2 时间格式不一致导致的展示错乱后端返回的时间字段是java.util.DateJSON序列化后默认是一大串时间戳数字前端拿到后需要自己转格式。如果不做处理页面上直接显示时间戳。这个问题的解决方案有两个层面后端层面在实体类的日期字段上加上JsonFormat注解统一输出为yyyy-MM-dd HH:mm:ss格式JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private Date createTime;前端层面如果要展示的是日期如排班的schedule_date直接用字符串截取即可如果要展示日期时间如预约创建时间建议封装一个日期格式化工具函数。我推荐后端统一格式化原因很简单前端有多个页面都要用时间每个页面各自处理格式容易出错后端统一返回字符串格式前端用着最省心。5.3 uniapp在H5端和微信小程序端的差异处理同一个系统我编译了两个端踩了不少差异性的坑第一顶部导航栏高度不一致。H5端的导航栏是浏览器自带的小程序端是微信的胶囊样式用固定像素定位的元素在两端显示位置会偏差。解决方式是用uniapp提供的uni.getSystemInfoSync()获取statusBarHeight和系统信息动态计算导航栏高度。第二支付和登录能力不同。H5端没法用wx.loginiOS的H5在部分场景下调用微信支付也会有兼容问题。我在代码里做了平台判断编译条件编译的方式H5端走账号密码登录小程序端走微信登录。第三页面滚动机制不同。小程序端页面默认自带滚动H5端在浏览器里也是正常滚动但如果你用了自定义的scroll-view组件两个端的滚动效果和滚动事件触发频率有差异。预约记录列表我在H5端用页面滚动小程序端也用页面滚动避免scroll-view的兼容差异。5.4 接口联调慢的根因数据库连接的坑联调时发现一个很奇怪的现象每次请求第一次访问需要2-3秒后面再访问就很快过一会儿不访问又变慢了。排查思路是这样的先看后端日志发现第一次访问时日志里出现了数据库连接等待的警告。原因是c3p0连接池初始连接数为0第一次请求需要创建连接所以慢后续请求复用连接池所以快等连接闲置超时被回收后下一次又需要重新创建。解决方案有两种一是把连接池初始连接数和最小连接数调大比如initialPoolSize10, minPoolSize5让服务启动时就预建连接二是配置连接池的回收检测机制避免连接被MySQL服务端超时断开。我用了第一种方案同时把C3P0的testConnectionOnCheckout设置为true确保拿到的连接一定可用。这个问题在开发环境不明显因为数据库和开发机经常交互连接不容易被回收但部署到测试环境后问题会频繁暴露。5.5 跨域问题的完整解决过程H5端联调时遇到跨域报错信息是Access-Control-Allow-Origin。这个问题的本质是浏览器同源策略限制前端页面在localhost:8080后端接口在localhost:9090协议、域名、端口有一个不同就是跨域。解决方式在SSM后端加一个CORS过滤器实现WebMvcConfigurer接口配置允许跨域的来源、请求头、请求方法Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:8080) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }这里有一个容易忽略的坑如果前端请求携带了Authorization请求头但allowedHeaders里没配置Authorization请求一样会被拦截。我初期就是漏了这行导致跨域问题表现得很诡异登录接口能通但业务接口全挂。5.6 号源扣减在压测中暴露的死锁问题给挂号接口做并发压测时出现了一个让我头疼的问题偶尔会有请求返回超时日志里显示死锁检测到了。排查发现是我在扣减号源的事务里先更新排班表的left_count再插入挂号记录而另一个事务可能先插入挂号记录再更新排班表——虽然我在代码里统一了顺序但MyBatis的缓存和SQL执行顺序在某些情况下会打乱。解决方式是保证所有数据库操作严格按照同一个顺序执行先更新排班表后插入挂号表并且在事务方法中不写可能造成锁竞争的外部调用。6. 部署上线与后续扩展的个人经验关于部署SSM项目的传统方式是打包成WAR包放进Tomcat的webapps目录这种部署方式现在仍然实用尤其是接老项目的场景。我在实际部署时用了Tomcat 8.5 JDK 8的镜像版本因为SSM框架对更高版本JDK的依赖兼容性已经测试得很成熟了懒得折腾。部署前要做的完整性检查清单我列一下数据库初始化脚本执行一遍确认所有表和数据能正常创建配置文件里的数据库连接地址改为测试环境地址账号密码不要硬编码在代码中后端打包前跑一遍单元测试至少保证核心的挂号、排班接口逻辑测试通过前端uni-app项目执行编译生成小程序代码后在微信开发者工具中打开确认能正常请求后端接口如果小程序要正式上线后端域名必须是HTTPS协议且在小程序后台配置request合法域名真机预览时务必关闭开发者工具的不校验合法域名选项否则开发环境能跑、上线就挂扩展方向上我整理了几个比较实用的思路第一对接阿里云短信服务。预约成功、就诊提醒和取消通知都能通过短信触达用户体验会提升很多。在小程序端还可以用订阅消息通知通常效果更好。第二引入Redis缓存热门科室和医生信息降低数据库查询压力。医疗系统的访问特点是热点集中比如某三甲医院的热门科室一天可能被几十万次查询缓存收益非常明显。第三增加管理后台。管理后台不一定要用uniapp做用Vue Element Plus做一套Web管理端反而更轻量。管理员可以管理科室、医生、排班、号源配置和用户数据后端复用同一套SSM接口前端单独写一套管理界面即可。最后分享一个根项目相关的经验如果你是把这套系统当作毕业设计或面试作品来做建议把重点放在两个地方——一是把号源并发扣减的方案讲透这是技术深度所在二是把整个预约到就诊的状态流转设计讲清楚这是业务完整度所在。这两点讲明白了这套项目的价值就完全展现出来了。