2026/9/19 22:18:51

Spring Boot医疗服务系统:从表设计到并发挂号防超卖

Spring Boot医疗服务系统:从表设计到并发挂号防超卖 简介这是一份基于Java的医疗服务系统设计与实现文档面向计算机相关专业毕业生、医疗信息化方向研发人员以及刚开始接触Spring Boot框架的读者可用来理清信息管理系统从需求分析到开发上线的完整流程。包内只有一个docx文档大小约7.09MB内含摘要、Abstract、目录、系统概述、各模块设计说明等结构完整适合作为毕业设计论文或项目设计报告的参照。系统围绕管理员、普通村民、乡村医生等角色覆盖健康档案、学习培训、考核信息、医疗地图、医疗药品购买、公告发布等核心业务采用MySQL存储数据并借助Spring Boot简化配置、提高开发效率。读者既可借此了解医疗场景下的数据建模与功能划分思路也可复用其中的接口设计、页面布局和关键代码写法为课程设计或实际项目实施提供直接参考。这份文档已有51人学习属于医疗信息化领域的高价值参考资料。1. 医疗服务系统先学会处理“事务流”很多 Java 课程设计和毕业设计把医疗服务系统写成了“博客系统换皮”给用户表加一个 role 字段把挂号做成一条 insert把医生端做成数据管理后台。等到答辩或真实演示时才发现真正的医疗服务系统核心根本不是增删改查而是事务流——号源怎么防超卖、处方怎么记账、医生和患者的数据边界怎么隔离。这些才是面试官和评审真正盯着看的地方也是“设计与实现”这个标题里最值钱的部分。这套系统在 Java 后端最常见的落地形态是Spring Boot 提供接口和事务管理MyBatis-Plus 做数据持久化MySQL 存业务数据前端用 Vue 或 Thymeleaf 都行核心在服务层。它的难点不在“页面多不多”而在“并发挂号时号源是否会扣成负数”“一个处方涉及多张表时事务边界画在哪”。本文按从零搭建的角度把表设计、挂号防超卖、处方事务、权限边界和上线前的调优一条线讲完。适合正在做课程设计、准备毕设答辩、或者刚接触 JavaWeb 想完整走一遍业务系统的读者。2. 技术选型与工程骨架为什么是 Spring Boot MyBatis-Plus2.1 Spring Boot 3 JDK 17 是当前最稳的组合先说选型。医疗服务系统这类业务系统技术栈首要目标是“开发效率高、出问题好查、资料好找”。Spring Boot 2.7 和 Spring Boot 3.x 现在都能用但新项目我一般直接上 Spring Boot 3.2配合 JDK 17。JDK 17 是 LTS 版本Spring Boot 3 官方最低要求就是它网上关于“java环境变量配置”“JDK 17 源发行版报错”这类问题的解决方案已经非常成熟踩坑成本低。有人会问为什么不用 Spring Cloud 那套微服务医疗服务系统的业务边界很清楚挂号、处方、药品、科室、用户单体能扛住绝大多数场景。微服务带来的分布式事务、服务发现、链路追踪问题在这个体量下是纯粹的负担。单体 良好的模块分包是这类系统最可靠的方案。2.2 工程分包与依赖配置工程分包按“业务域”而不是按“技术层”来切这一点对后续维护影响很大。com.hospital ├── common # 统一返回体、异常处理、工具类 ├── config # 配置类MyBatis-Plus、拦截器、CORS ├── module │ ├── user # 用户、角色、登录 │ ├── department # 科室 │ ├── schedule # 医生排班、号源 │ ├── registration# 挂号 │ ├── prescription# 处方、药品 │ └── stats # 统计报表 └── HospitalApplication.javapom.xml 里的核心依赖如下尽量精简不要引入用不到的包dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency /dependencies逻辑说明MyBatis-Plus 不是必须的但在这个系统里它能省掉大量单表 CRUD 代码——BaseMapper自带增删改查分页插件一配就能用。JWT 用于登录态管理比 Session 更适合前后端分离。Lombok 减少 getter/setter 样板代码。若你的电脑上装的是 JDK 8那就把 Spring Boot 版本降到 2.7.x同时把 MyBatis-Plus 的依赖保留其他不用动。2.3 application.yml 里的三个关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0参数说明serverTimezoneAsia/Shanghai和jackson.time-zoneGMT8这两个必须同时存在否则日期字段会出现“差 8 小时”的问题这是 java 时间处理里最常见的坑之一。logic-delete-field: deleted表示所有表统一用deleted字段做逻辑删除查询时 MyBatis-Plus 会自动追加WHERE deleted 0避免物理删除后数据无法追溯——医疗系统里处方、挂号记录是绝不能物理删除的。启动类没什么特殊标准写法SpringBootApplication MapperScan(com.hospital.module.**.mapper) public class HospitalApplication { public static void main(String[] args) { SpringApplication.run(HospitalApplication.class, args); } }MapperScan指定 Mapper 接口所在包注意用**匹配多级包这样后续按业务域新增模块时不需要再改启动类。3. 数据库设计六张表撑起挂号到开方的完整链路3.1 核心表结构与设计要点医疗服务系统不需要几十张表六张核心表就能跑通主流程。先看表结构设计这是“设计”二字最直接的体现。下面这 6 张表的关系是用户表里有患者和医生两种角色医生挂在科室下医生有排班排班产生号源患者挂号的记录是挂号单医生给患者开处方处方关联药品。表名核心字段说明userid, username, password, real_name, role, department_idrole 区分 ADMIN / DOCTOR / PATIENTdepartmentid, name, description科室如内科、外科doctor_scheduleid, doctor_id, dept_id, schedule_date, start_time, end_time, total_slots, remain_slots, version排班与号源registrationid, patient_id, schedule_id, doctor_id, reg_time, status, fee挂号单prescriptionid, reg_id, doctor_id, patient_id, diagnosis, total_amount, create_time处方主表prescription_itemid, prescription_id, drug_name, spec, unit_price, quantity, amount处方明细一主多从3.2 为什么 user 表不建物理外键一个必须说明的设计决策所有表都不建物理外键只保留逻辑外键。比如registration.doctor_id指向user.id但不在数据库层面声明FOREIGN KEY。理由是医疗服务系统的写入路径很固定靠代码保证引用完整性而外键会在高并发插入时增加锁开销还会让分库分表和后续重构变得极为困难。mysql 在互联网公司里的实践早就默认了这个方案面试时能被问到“为什么不用外键”也是加分项。用户表的建表语句可以这样写CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, real_name varchar(50) NOT NULL, role varchar(20) NOT NULL COMMENT ADMIN/DOCTOR/PATIENT, department_id bigint DEFAULT NULL COMMENT 医生所属科室, deleted tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明密码字段长度设为 100 而不是 64是因为后面如果用 BCrypt 加密密文长度会超过 60 位。role字段用字符串不用数字是为了代码可读性代价是索引稍大但这个量级无所谓。update_time使用ON UPDATE CURRENT_TIMESTAMP自动维护不需要在 Java 代码里手动 set省去一类“数据改了但时间没变”的 bug。3.3 号源表的设计是防超卖的关键doctor_schedule表是整个系统并发压力最大的点。两个字段决定成败total_slots int NOT NULL COMMENT 总号源数, remain_slots int NOT NULL COMMENT 剩余号源数, version int NOT NULL DEFAULT 0 COMMENT 乐观锁版本号total_slots是医生一次排班放出的号源总数remain_slots是当前剩余量。每次挂号的扣减操作必须同时满足“两个条件 一个字段更新”UPDATE doctor_schedule SET remain_slots remain_slots - 1, version version 1 WHERE id #{scheduleId} AND remain_slots 0 AND version #{version};这段 SQL 是防超卖的核心。remain_slots 0在数据库层面保证不会扣成负数version #{version}保证乐观锁生效。如果返回的受影响行数为 0说明号源已被抢光或版本已被其他请求更新此时 Java 代码中要抛业务异常。这套方案在单体应用里足够可靠避免了对整行数据加锁的性能损耗。4. 核心业务流实战挂号防超卖、处方事务、统计报表4.1 挂号流程的接口实现挂号接口的核心逻辑是把“扣号源”和“插入挂号单”放在同一个事务里。看代码时重点看两个方法如何配合Service public class RegistrationService { Resource private DoctorScheduleMapper scheduleMapper; Resource private RegistrationMapper registrationMapper; Transactional(rollbackFor Exception.class) public void register(RegisterRequest request) { // 1. 扣减号源, 返回受影响行数 int rows scheduleMapper.decreaseRemainSlots(request.getScheduleId(), request.getVersion()); if (rows 0) { throw new BizException(号源已被抢完或已过期请刷新后重试); } // 2. 插入挂号单 Registration reg new Registration(); reg.setPatientId(request.getPatientId()); reg.setScheduleId(request.getScheduleId()); reg.setDoctorId(request.getDoctorId()); reg.setStatus(PAID); reg.setFee(request.getFee()); registrationMapper.insert(reg); } }逻辑说明第一步执行上文那条乐观锁 UPDATE返回值rows是受影响行数。如果为 0说明更新失败——可能remain_slots已经是 0也可能version不匹配导致并发冲突。此时直接抛异常事务回滚不会产生“挂号单插进去了但号源没扣”的脏数据。第二步插入挂号单因为外层加了Transactional两步要么都成功要么都回滚。对应 Mapper 里的方法用注解写即可Update(UPDATE doctor_schedule SET remain_slots remain_slots - 1, version version 1 WHERE id #{scheduleId} AND remain_slots 0 AND version #{version}) int decreaseRemainSlots(Param(scheduleId) Long scheduleId, Param(version) Integer version);参数说明version从前端传入前端在查询排班列表时拿到本次的版本值。也就是说用户看到某个排班时的快照版本是多少提交挂号时就带这个版本。如果两个用户同时看到 version5 并同时提交数据库的 UPDATE 语句是行级锁串行执行的第一个执行成功version 变为 6第二个执行时WHERE version 5匹配不到返回 0 行被拒绝。这是典型的乐观锁并发控制适合读多写少、冲突概率不高的场景。4.2 处方创建的事务边界与自调用坑处方创建涉及三张表挂号单状态、处方主表、处方明细表。这三步必须处于同一事务中。直接用Transactional就行但有一个“事务自调用失效”的经典坑必须绕开Service public class PrescriptionService { // 错误示例: createInner 被同类内部调用, Transactional 失效 Transactional(rollbackFor Exception.class) public void createPrescription(PrescriptionRequest req) { this.createInner(req); // 事务失效! } }Transactional底层依赖 Spring AOP 动态代理默认只对经过代理对象的外部调用生效。this.createInner()是直接调用内部方法绕过了代理注解形同虚设。这也是 Java 面试里“动态代理”和“事务失效场景”常考的知识点。正确做法是把内部逻辑拆到另一个 Service 中注入调用或者在同一方法内完成全部逻辑。以后看代码时凡是遇到“一个类内部方法互相调用 事务不生效”第一反应就该是这个原因。4.3 报表统计用 Java 流式编程聚合数据医疗服务系统往往需要“本月各科室接诊量”“某医生近 30 天处方总额”这类统计。这类需求不一定要写复杂 SQL把数据查出来用 Java Stream 聚合往往更直观public ListDeptStatsVO getDeptStats(LocalDate start, LocalDate end) { ListRegistration list registrationMapper.selectList( new LambdaQueryWrapperRegistration() .between(Registration::getRegTime, start.atStartOfDay(), end.plusDays(1).atStartOfDay()) .eq(Registration::getStatus, PAID)); return list.stream() .collect(Collectors.groupingBy(Registration::getDoctorId, Collectors.counting())) .entrySet().stream() .map(e - { DeptStatsVO vo new DeptStatsVO(); vo.setDoctorId(e.getKey()); vo.setCount(e.getValue()); return vo; }) .collect(Collectors.toList()); }逻辑说明先用 MyBatis-Plus 的LambdaQueryWrapper查出时间范围内的所有挂号记录再按医生 ID 分组计数。这里用between的右边界是end.plusDays(1).atStartOfDay()目的是把结束日当天的时间包含进去否则查到的是end 日 00:00:00之前的数据少算一天。Collectors.groupingBy和Collectors.counting()是 Java 流式集合操作里最常用的统计组合比写嵌套 SQL 好调试得多。5. 权限控制与数据边界JWT 拦截器和角色校验5.1 基于 JWT 的登录态管理医疗服务系统的接口必须区分角色权限患者只能挂号和查自己的记录医生只能看自己的排班和处方管理员才能改基础数据。这里用 JWT 而非 Session因为前后端分离后 Session 的跨域和集群共享都是额外成本。Component public class JwtUtil { private final SecretKey key Keys.secretKeyFor(SignatureAlgorithm.HS256); public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 120)) .signWith(key) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } }参数说明HS256是对称加密算法生产环境 SecretKey 要从配置中心读取不能硬编码setExpiration设置 2 小时过期医疗系统操作时长有限这个值合理——太短用户频繁登录太长 token 泄露风险高。claim(role, role)把角色塞进 token拦截器直接取不用每次都查库。5.2 拦截器 注解实现接口鉴权一个登录拦截器加一个RequireRole注解可以覆盖大部分权限场景。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; // 接口没标注解则不需要权限 } String token request.getHeader(Authorization); // 解析 token, 校验角色... return true; } }接口上这样用RequireRole({ADMIN, DOCTOR}) GetMapping(/prescription/list) public Result? listPrescriptionsByDoctor(RequestParam Long doctorId) { // 业务逻辑 }5.3 数据越权光有角色还不够角色校验解决“谁能访问这个接口”但解决不了“用户访问了别人的数据”。医疗服务系统的数据边界要求更严格患者只能查自己的挂号单医生只能看自己的患者。常见做法是在 SQL 层强制拼接当前登录用户的 ID而不是信任前端传参。例如医生查处方列表时doctor_id不从前端取而是从当前登录用户上下文取// 错误做法: 前端传哪个医生ID就查哪个 Long doctorId request.getParameter(doctorId); // 正确做法: 从 JWT 解析出的 userId 作为强制过滤条件 Long currentUserId UserContext.getUserId(); LambdaQueryWrapperPrescription wrapper new LambdaQueryWrapper(); wrapper.eq(Prescription::getDoctorId, currentUserId);这种强制数据隔离的思路比单纯校验“登录状态”更接近生产环境的安全要求。系统里凡涉及列表查询都应追问一句“这里会不会查到别人的数据”。权限矩阵可以整理成一张表方便答辩时展示给评审接口ADMINDOCTORPATIENT科室增删改YNN排班管理YY(本人)N挂号NNY处方创建NY(本人)N处方查看YY(患者需授权)Y(本人)6. 上线前的三关分页、并发验证与时间格式6.1 分页插件的配置与慢 SQL 排查医疗服务系统的挂号记录和处方记录会随时间线性增长不配置分页的话全表查询会越跑越慢。MyBatis-Plus 分页插件配置如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)限制单次查询最多 500 条防止有人传一个超大页码把数据库打满。排查慢 SQL 时先看日志中 MyBatis 打印的 SQL再用EXPLAIN验证是否走了索引。registration表按patient_id和reg_time建立联合索引是查询频率最高的条件。6.2 并发挂号压测与 version 冲突观察代码写完了怎么验证防超卖真的有效用 JMeter 或干脆写一段模拟程序启动一个线程池50 个线程同时对同一个排班发起挂号。理想结果是remain_slots从 10 扣到 0成功插入的挂号单恰好 10 条其余 40 条抛异常。如果成功条数大于 10说明乐观锁条件漏了如果少于 10说明有脏写。观察异常日志中是否大量出现“号源已被抢完或已过期”这是预期内的现象。6.3 时间字段的三处一致性时间问题是这类系统最容易在验收时暴露的问题。三处必须一致MySQL 连接参数里的serverTimezone、Jackson 的time-zone、以及服务器操作系统时区。通常做法是统一使用Asia/Shanghai数据库连接字符串和 Java 层同时指定不依赖服务器默认时区。前端传参时统一使用yyyy-MM-dd HH:mm:ss字符串后端用DateTimeFormat接收避免 LocalDateTime 在前后端序列化时出现精度或格式差异。这三处对齐了时间相关的 bug 基本就不会再出现。本文还有配套的精品资源点击获取