SpringBoot网上预约挂号系统:从业务建模到并发控制的完整实践

发布时间:2026/10/2 14:59:19
SpringBoot网上预约挂号系统:从业务建模到并发控制的完整实践 做了这么多年毕设辅导每年 3 月到 5 月咨询量最大的选题里网上预约挂号系统绝对排在前三。Java、SpringBoot、Web 这三个关键词一组合配上医院管理的现实场景看起来又正统又有实用价值很多同学一眼就相中了。但说句实在话我见过太多把这个项目做成纯增删改查的案例——页面倒是不少科室管理、医生管理、用户管理点进去全是列表加表单。搁在答辩台上老师一问两个医生同时被挂最后一号怎么办就直接卡壳。这篇文章不打算给你复制一堆没营养的代码而是从一个真正把项目做完、也陪别人做完过无数遍的角度把网上预约挂号系统从需求拆解、技术选型、数据库设计到并发控制、部署答辩的完整链路捋一遍。不管你是拿它做毕业设计还是只是想练手 SpringBoot 项目这套思路都够你直接照搬。1. 一个老选题的含金量为什么挂号系统年年有人做却总有人做不透1.1 挂号业务天然具备闭环价值先说个很多人没意识到的事实网上预约挂号系统之所以成为毕设常青树不是因为它简单而是因为它的业务逻辑足够完整刚好卡在一个适合练习又不过度复杂的区间。一个真正能用的预约挂号平台必须解决四件事用户端注册登录、浏览科室、查看医生排班、选择时段、确认挂号、支付或模拟支付、查看预约记录、取消预约。医生端查看自己某天的排班、查看已预约患者列表、标记接诊状态。管理端科室管理、医生信息管理、排班规则设置、号源总量设置、停诊处理。系统层号源扣减的并发控制、预约状态的超时回收、不同角色访问权限隔离。这四个层面串起来其实就是一个小型信息系统的完整业务闭环。你需要做前端页面、写后端接口、设计数据库表、处理并发、搞定时任务、做权限控制。这些东西在一个项目里全都遇到又全都能做完不至于像电商秒杀那样难度失控。对毕设来说可控且有深度这就是它最大的含金量。1.2 挂羊头卖狗肉的系统往往死在这三个地方我在看别人代码时发现凡是做得不好的预约挂号系统基本都中了一样的招把预约做成了留言用户提交一条记录管理端看一眼就算完根本没有号源库存、没有状态流转、没有时间约束。状态字段只有一个或者说所有订单永远停留在已挂号没有待支付、已取消、已完成的状态区分。表之间关系混乱排班和医生、科室之间没有有效外键关联统计报表根本写不出来。这些问题本质上不是编码问题而是没有把业务规则吃透就开始建表了。所以这篇文章我会花很大篇幅在数据库设计和业务状态流转上因为这才是系统能不能打的关键。2. 技术选型背后的真实逻辑不是越新越好是越稳越好2.1 版本组合是第一个隐形坑SpringBoot 现在大版本已经来到 3.x很多教程也在推 JDK 17、JDK 21 的组合。但对于毕业设计这个场景我依然建议你选择SpringBoot 2.7.x JDK 8。原因很实在你查资料时搜到的 90% 的教程、错误解决方案都基于 2.x 和 JDK 8遇到问题几乎都能搜到现成答案而不是一个人对着新特性发呆。学校机房、导师本地的运行环境未必装了新 JDK用 JDK 8 兼容性最保险。2.7.x 是 2.x 的最后一个维护版本既没有 2.6 及以前版本的老旧感又避开了 3.x 刚迁移时的各种坑。如果你确实想用 SpringBoot 3.x JDK 17也不是不行但你得能接受javax变jakarta、部分第三方 starter 没跟上、旧教程里的代码复制过来直接爆红这类问题。对毕设来说任何额外的不确定性都是在给自己挖坑。前端这块如果你的基础一般直接选Vue 3 Element Plus Vite。为什么不用 Thymeleaf 服务端渲染因为现在学校答辩几乎默认你会点前后端分离且 Vue 出来的页面观感明显更现代给老师的印象分更高。用 Vue 也不用搞什么独立部署npm run build之后把dist目录里的文件扔进 SpringBoot 的static目录就行前后端还是一个进程跑起来规避了跨域和部署复杂度这部分细节我放到第 5 节专门讲。2.2 持久层框架MyBatis-Plus 是最稳答案持久层我推荐 MyBatis-Plus核心原因是它把 CRUD 的 SQL 全部封装好了你只需要写业务相关的复杂查询。比如分页查询医生列表Page对象一传selectPage直接返回连LIMIT都不用手写。另一个好处是它的LambdaQueryWrapper写条件查询非常自然按科室查医生、按日期查排班配 Lambda 表达式不用拼字符串改字段名也不会查不出东西。// 查询某科室下所有在职医生 ListDoctor doctors doctorMapper.selectList( new LambdaQueryWrapperDoctor() .eq(Doctor::getDeptId, deptId) .eq(Doctor::getStatus, 1) .orderByAsc(Doctor::getSort) );这种代码写起来快答辩时也讲得清楚。2.3 数据库与中间件的选择原则数据库统一用 MySQL 5.7 或 8.0这两个版本在学校环境里最常见。连接池用 Druid因为它的监控页面/druid/index.html在很多毕设答辩现场是加分项老师问你怎么排查慢 SQL时你可以直接打开监控给他看。至于 Redis——如果你只是做预约挂号我不建议为了炫技硬上 Redis。用户量根本没到需要缓存和分布式锁的量级数据库唯一索引加乐观锁更新已经足够。真正的业务瓶颈是号源扣减的并发问题这个用 SQL 层面的条件更新就能解决。与其引入一个你讲不明白的 Redis 整出缓存一致性黑锅不如把数据库事务搞透彻。如果导师明确要求引入中间件那再考虑加 Redis 缓存科室列表和做分布式锁也算有个交代。3. 表结构设计挂号系统的地基就是这么打的3.1 核心表与字段定义网上能找到的很多挂号系统表设计最大的问题是表太少一个挂号表想装下所有东西。我的表结构设计建议是七张表分工非常明确用户表sys_user字段类型说明idbigint主键自增usernamevarchar(50)登录账号唯一passwordvarchar(100)BCrypt 加密后的密码real_namevarchar(50)真实姓名phonevarchar(20)手机号roletinyint0-管理员 1-医生 2-患者dept_idbigint医生所属科室患者和管理员为 nullcreate_timedatetime创建时间把医生、管理员、患者放进同一张用户表用role字段区分是最省事且符合常规的做法。如果你非要把医生和患者拆成两张独立的表就会遇到一个尴尬问题权限认证时到底查哪张表Spring Security 或拦截器里的用户身份怎么统一同一张表加角色的方案登录逻辑只需一个selectByUsername简单干净。科室表department字段就四五个id、name、description、sort排序号、status启用状态。没什么需要特殊设计的但要注意给name加唯一约束不然录入时重复科室会出现两遍。医生信息表doctor_info虽然医生基础信息在sys_user里但职称、简介、擅长领域、头像这些医生特有属性不适合往用户表里堆所以单独建一个doctor_info表和sys_user一对一关联。排班表schedule这是整个系统的核心表之一字段类型说明idbigint主键doctor_idbigint医生用户 IDdept_idbigint所属科室work_datedate出诊日期time_slottinyint时段1-上午 2-下午total_countint该时段总号数remain_countint剩余号数statustinyint0-停诊 1-正常订单表appointment_order字段类型说明idbigint主键order_novarchar(32)订单号唯一user_idbigint挂号用户doctor_idbigint医生 IDschedule_idbigint排班 ID关联排班表visit_datedate就诊日期time_slottinyint就诊时段statustinyint0-待支付 1-已支付 2-已取消 3-已完成 4-已过期create_timedatetime创建时间pay_timedatetime支付时间此外还有一张通知/操作日志表可选记录用户取消、管理员停诊等关键操作答辩时可以讲你做了操作审计。3.2 号源设计的两种思路号源总量是放在 schedule 表里直接减还是单独建一张号源明细表这是很多人纠结的点。直接放排班表里改remain_count简单适合毕设和中小型医院只要用条件更新 行锁就能防超卖。单独建号源表每个时间段生成 N 条号源记录挂号时对某一条做状态变更。这种粒度的好处是能精确追踪哪个号被谁挂了但表数据量大、逻辑复杂毕设没必要。我强烈建议方案一。数据库表每多一张联表查询、事务一致性、答辩追问的风险就多一分。3.3 状态机让订单状态成为一条清晰的线好的系统订单状态一定是流转的而不是几个状态键随便摆着。我设计的状态流转是这样待支付(0) --用户支付-- 已支付(1) --医生接诊-- 已完成(3) | | |--用户取消-- 已取消(2) | |--超时未付-- 已过期(4)--|--停诊/改期-- 已取消(2)每个状态的前置条件必须校验。比如用户取消订单只能在待支付或已支付状态做停诊取消订单系统要把该排班下所有已支付订单置为取消并原路退款毕设里做模拟退款即可。这套状态机在代码里就是if (order.getStatus() ! 0) throw new RuntimeException(当前状态不允许取消);这样的判断逻辑不复杂但数据干净报表好写答辩也好讲。4. 核心业务实现从选科室到挂号成功代码到底怎么写4.1 挂号的完整接口链路我把用户端的操作流程拆成五个接口顺序就是页面的跳转顺序查询开启的科室列表GET /api/dept/list根据科室查医生GET /api/doctor/list?deptIdxx根据医生查排班GET /api/schedule/list?doctorIdxxdateyyyy-MM-dd提交挂号订单POST /api/appointment/submit模拟支付POST /api/appointment/pay?orderIdxx第 3 步有一个容易被忽略的细节排班查询不仅要返回日期时段和剩余号数还要把status和remain_count为 0 的时段标记成约满或停诊前端渲染时直接置灰。否则用户提交挂号时接口返回没号了体验很差。4.2 并发扣减号源一次 UPDATE 解决 90% 的问题这是整个系统最值得在答辩时展开讲的技术点。先看最常见也最蠢的写法// 错误示范 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule.getRemainCount() 0) { schedule.setRemainCount(schedule.getRemainCount() - 1); scheduleMapper.updateById(schedule); }这个写法在单用户测试时一切正常一旦两个请求同时读到remain_count 1两个都能通过 if 判断最终剩余号数变成 -1。经典的超卖问题。正确的解法是在 SQL 层面直接做条件更新UPDATE schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0 AND status 1配合 Java 代码里的判断int rows scheduleMapper.deductStock(scheduleId); if (rows 0) { throw new RuntimeException(该时段号源已被抢完); }UPDATE语句本身会加行锁remain_count 0的条件让数据库保证不会减成负数rows 0表示更新失败也就是没号了。原理上这就是一次 CASCompare And Swap思想但用的是数据库的行锁实现。对毕业设计来说这个细节比写十页 Redis 分布式锁更能让老师眼前一亮。4.3 防止同一用户重复挂号除了防超卖还要防一人多挂。数据库中给(user_id, schedule_id, status)加一个部分唯一索引不现实因为 status 会变化。所以我通常的做法是在提交订单前查一次该用户在当前排班是否有非取消状态的订单Long count orderMapper.selectCount( new LambdaQueryWrapperAppointmentOrder() .eq(AppointmentOrder::getUserId, userId) .eq(AppointmentOrder::getScheduleId, scheduleId) .in(AppointmentOrder::getStatus, Arrays.asList(0, 1, 3)) ); if (count 0) { throw new RuntimeException(您已挂过该医生此时段的号请勿重复挂号); }逻辑上待支付、已支付、已完成都算已占用号源已取消和已过期不算。这个判断要与减号源放在同一个事务里先查重、再减库存、再插入订单三个操作要么都成功要么都失败。4.4 超时未支付与号源释放很多系统做出来用户提交订单后不支付号源就永远占着。解决思路很简单一个Scheduled定时任务每分钟跑一次把创建时间超过 15 分钟且状态为待支付的订单查出来批量置为已过期同时恢复对应的号源数量。Scheduled(cron 0 * * * * ?) Transactional(rollbackFor Exception.class) public void timeoutOrderCancellation() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListAppointmentOrder expiredOrders orderMapper.selectList( new LambdaQueryWrapperAppointmentOrder() .eq(AppointmentOrder::getStatus, 0) .lt(AppointmentOrder::getCreateTime, deadline) ); for (AppointmentOrder order : expiredOrders) { order.setStatus(4); // 已过期 orderMapper.updateById(order); scheduleMapper.releaseStock(order.getScheduleId()); // 号源加回 // 注意这里 releaseStock 也要带条件防止排班已停诊或其他状态变更 } }这里有两个细节值得注意一是启用定时任务需要在启动类加EnableScheduling也是最常见的遗忘点二是releaseStock的 SQL 也必须带WHERE status 1否则排班停诊了你还把库存加回去数据就乱了。另外因为定时任务每分钟跑一次存在极端情况下用户在第 14 分 59 秒支付、任务在第 15 分 00 秒把订单取消的竞争所以做支付动作时要校验订单状态仍为待支付。不写分布式锁也没问题数据库更新条件能挡得住。5. 登录态、权限控制与 Vue 项目的加载5.1 用 JWT 还是 Session毕设项目我这里推荐JWT 拦截器方案。它的好处是前端拿到 token 存 localStorage每次请求带上Authorization请求头后端拦截器解析 token 拿用户身份整个链路无需考虑分布式会话。而且无状态认证是面试和答辩时能讲清楚的高频考点。关键代码就在拦截器里public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册等白名单接口 String uri request.getRequestURI(); if (uri.startsWith(/api/auth/)) { return true; } String token request.getHeader(Authorization); // 解析失败直接抛 401由全局异常处理返回统一 JSON Long userId JwtUtils.parseToken(token); request.setAttribute(userId, userId); return true; } }权限这块不要用 Spring Security太重了毕设一个多级菜单系统完全用不上它的过滤器链。用拦截器 注解就够了比如给管理端接口加一个RequireRole(admin)注解在拦截器里判断sys_user.role与注解要求是否匹配不匹配返回 403。这套是自己能讲清楚的代码比引入了 Spring Security 却配置不明白光荣得多。5.2 Vue 打包进 SpringBoot 的完整步骤这是网上搜不到完整版的实操细节。前后端分离开发时你在本地起 Vue 的 dev server端口 5173接口通过 Vite 代理转发到 SpringBoot端口 8080。真正要部署成一个 jar 时执行npm run buildVite 默认会生成dist目录里面包含index.html、assets等静态资源。然后把dist里所有内容不是 dist 目录本身复制到 SpringBoot 项目的src/main/resources/static目录下。接着有一个必踩的坑Vue Router 如果用createWebHistory()刷新页面时会出现 404因为请求路径找不到对应的 Controller。解决办法有两个路由改为createWebHashHistory()就是 URL 中带#。做法简单无需后端配置。保留 history 模式在 SpringBoot 里写一个非接口路径统一转发到 index.html的 ControllerController public class IndexForwardController { RequestMapping(value {/, /index.html}) public String index() { return index.html; // 注意 SpringBoot 会去 static 目录找 } }更彻底的做法是重写一个内部的资源处理器处理/{path:[^\\.]*}这种正则匹配把所有不带文件后缀的路径转发到前端入口。但其实这对于毕设已经够复杂了。我的建议是直接用 hash 模式一个字符都不用改后端稳得一批。只有老板明确要求URL 必须干干净净你再去折腾 history 模式。功能和分数上这两种方案没有区别。5.3 文件上传与静态资源路径医生的头像图片或者后续可能的用户证件上传都需要文件存储。最简单的方案application.yml里配一个自定义上传目录upload: path: /data/hospital/upload/Controller 接收MultipartFile写入该目录下的yyyyMMdd子文件夹文件名用 UUID 防重名。返回给前端的访问 URL 是/files/xxxx.jpg然后加一个资源映射配置Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceHandler(file: uploadPath); } }这里有一个毕设里非常常见的问题把文件写到项目根目录的upload文件夹里打包成 jar 后路径变成只读图片显示不出来。所以我会把上传路径配置为绝对路径/data/hospital/upload/这类与项目本身解耦。另外提醒一句本地 Windows 调试路径用D:/upload/Linux 服务器用/data/...同一个配置项在不同环境要改值。答辩演示时用本地路径没问题但讲生产部署时要说得清这条路径是独立于 jar 的。6. 答辩时最容易被追问的 SpringBoot 底层问题6.1 自动装配到底自动了什么答辩被问概率最高的一类问题就是 SpringBoot 自动装配原理。很多同学张口就是SpringBoot 简化了配置但这句空话老师不会满意。真正能过关的答法是SpringBootApplication是组合注解核心是EnableAutoConfiguration。这个注解会导入AutoConfigurationImportSelector它是一个ImportSelector会扫描所有依赖包里的META-INF/spring.factories文件SpringBoot 2.7 之前或者META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件2.7 及以后。文件里列了一堆自动配置类比如DataSourceAutoConfiguration、MybatisPlusAutoConfiguration。这些配置类上有大量ConditionalOnClass、ConditionalOnMissingBean条件注解只有当 classpath 下有对应依赖类、且容器里没有用户自定义的 Bean 时自动配置才生效。你可以这样给老师举例我引入spring-boot-starter-data-redis时classpath 里多了 Redis 相关类自动配置就帮你创建了RedisTemplate这个 Bean。如果我自己写了一个RedisTemplateString, Object的Bean方法条件注解发现已有该类型的 Bean我的配置优先级更高自动配置就不会覆盖我的自定义实现。这套回答完完全全讲清楚原理比背十句概念有用。6.2 事务为什么有时候失效你的挂号下单接口加了Transactional(rollbackFor Exception.class)这是加分项。但老师可能追问事务什么时候会失效常见的场景你要能答出来同类内部方法调用this.submit()调this.doCreate()doCreate上的事务注解不生效因为 Spring 事务基于 AOP 动态代理this调用不走代理对象。方法不是public时事务不生效因为代理技术在私有方法上会失效。抛出的异常不是RuntimeException而是检查异常时默认不回滚所以rollbackFor Exception.class必须写。数据库引擎是 MyISAM 时事务直接失效MySQL 5.5 之前的默认引擎就是这个。每个点你都能结合挂号系统的场景举一个例子比如我在 OrderServiceImpl 内部调用 updateOrder 方法时要主动注入自己再调用或拆到其他 Service 里否则订单更新失败不会触发回滚号源却已经扣了数据就脏了。6.3 Bean 生命周期与三级缓存高频追问SpringBoot 面试和答辩的另一个高频问题就是Bean 生命周期和循环依赖。踩过的坑是如果 A 依赖 B、B 又依赖 ASpring 会通过三级缓存提前暴露 A 的半成品实例来解决。你要能说出三级缓存分别是singletonObjects成品缓存、earlySingletonObjects半成品缓存、singletonFactories单例工厂缓存。毕设里你如果遇到了循环依赖大多数时候不是去研究三级缓存而是理清设计问题——把 Service 之间的互相调用关系画出来90% 的循环依赖都是因为拆分不合理用构造器注入来解决或改成按业务拆层。例如挂号 Service 需要调用户 Service 查用户信息用户 Service 又调挂号 Service 查订单数量这就循环了。正确的做法是把查订单数量逻辑放到挂号的 Mapper 层直接查而不是反过来依赖。6.4 数据库连接池参数怎么配置另一个追问方向是数据源。Druid 连接池的参数你在application.yml里配了但能不能说清每个参数的意义要能说出来spring: datasource: druid: initial-size: 5 # 启动时创建 5 个连接 min-idle: 5 # 最小空闲连接数 max-active: 20 # 最大活跃连接数 max-wait: 60000 # 获取连接超时 60 秒讲解示例我系统平时的并发也就几十个人20 个最大连接足够超过 20 个请求同时进来时第 21 个请求会在 60 秒内等待一个空闲连接否则报异常。这个配置既避免了连接浪费也防止了无限制创建连接的失控。7. 收尾前再给你几条少有人提的优化建议整套做下来系统已经能跑、能演示、能答辩了但如果你还想让它在优化层面比别人高一个身位我实测过这几个改动性价比极高。第一给订单号生成加一个前缀 时间戳 随机数的规则。PO、APAppointment、日期、6 位随机数字的组合例如AP20250601123045001。这不仅让订单表看着专业还能在日志里凭订单号前缀快速定位业务类型。第二在管理端的排班管理页面增加一个一键生成下周排班按钮。排班是重复性操作手动一条条加排班会把人逼疯。实现上就是填一个开始日期、结束日期、每个时段号源数后端循环创建 schedule 记录。这个功能一出来你的系统在导师眼里就不是玩具了。第三科室查询接口加一个简单的 Redis 缓存也行但如果你没引入 Redis至少要在 Service 层用ConcurrentHashMap做一个简单的本地缓存或加一个Cacheable注解的说明。其实菜鸟与老手的区别往往就在于这些能意识到性能问题并采取合理行动的细节。我在带项目的过程中最深的体会是网上预约挂号系统这个选题下限很低是个套壳 CRUD 也能跑上限却不低号源并发控制、订单状态机、定时任务回收、权限拦截器、前后端部署这些技术点每一个都值得在答辩时被展开追问五分钟。前者做到及格后者做到优秀差别不在你用没用什么高深框架而在你对业务和代码背后的原理理解得到底透不透。把这篇文章里的路线走一遍把每个问题的因果想清楚这项目就不只是完成而是真正长在你身上的作品了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询