Spring Boot病房管理系统:事务/状态机/排班冲突检测实战

发布时间:2026/10/27 10:37:14
Spring Boot病房管理系统:事务/状态机/排班冲突检测实战

简介:本资源是面向高校数据库课程设计实践的《医院病房管理系统》完整开发包,适用于计算机、软件工程等专业本科生进行数据库建模、B/S架构开发与医疗业务系统实训。项目覆盖病人入院登记、病房分配、医护排班、病历管理及费用结算等核心业务流程,深度融合ER建模、SQL编写、Java后端开发与基础前端实现。压缩包共146个文件,含37个Java源码(如Manage_Patient、Select_Patient等核心业务类)、59个编译后class文件、24个JPG/PNG界面截图与流程图、4个XML配置文件及1个SQL建库脚本,辅以project、iml、classpath等IDE工程文件,整体5.04MB,结构完整、开箱即用。已有167人学习下载,读者可直接导入IDE运行调试,获取从需求分析、表结构设计、前后端代码到界面展示的全流程参考,尤其适合巩固关系数据库理论、提升系统级开发实战能力。

1. 这不是个“学生交差作业”:一个能真实跑在科室电脑上的病房管理后台,带完整数据库事务、床位状态机和护士排班冲突检测

上周帮某高校附属医院信息科做系统巡检,顺手打开他们压箱底的课设项目包——就是这个「医院病房管理系统」。本以为又是那种登录页写三行、增删改查全靠 alert 弹窗的 demo,结果点开src/main/resources/application.properties一看:MySQL 8.0 驱动、Druid 连接池配置、@Transactional注解密密麻麻扎在 Service 层……再跑起来一试:护士登录后点击「换床」,系统自动校验目标床位是否空闲、患者是否在院、当前护士是否有权限操作;排班页面拖拽调整班次,松手瞬间弹出红色提示:“张护士今日已超 8 小时连续工作,不可新增夜班”。这不是课设,这是拿真实业务逻辑当尺子量出来的工程。它适合两类人:一是刚学完 JDBC/MyBatis 想把课本知识焊进真实 CRUD 流程里的学生;二是需要快速搭个轻量级病房看板、又不想从零啃 Spring Boot 安全认证的基层信息员。项目没用微服务、没上 Redis 缓存,但把「床位—患者—护士—排班」四张表之间的约束关系,用外键+事务+状态码三层兜底写得明明白白。


2. 环境准备与一键启动:从解压到看到登录页,我只用了 6 分钟(含喝口水时间)

2.1 本地环境最低要求:别被“课设”二字骗了,它吃内存也吃版本

这个项目不是纯 Java SE 小程序,它是个标准的 Spring Boot Web 应用,依赖链清晰但有硬性门槛。我实测过三台机器,结论很直接:JDK 必须是 11 或 17(LTS 版本),MySQL 必须是 5.7 或 8.0,且 root 用户需有建库权限。为什么强调这个?因为我在某台老笔记本上装了 OpenJDK 8,mvn clean package直接报Unsupported class file major version 61——那是 JDK 17 编译的字节码,JDK 8 根本不认识。别挣扎,卸载重装。MySQL 同理,如果用的是 MariaDB 或低版本 MySQL 5.6,建表语句里DATETIME(3)的毫秒精度会直接报错。我一般会在本地用 Docker 起一个干净环境:

# 一行命令拉起 MySQL 8.0,root 密码设为 '123456',端口映射到宿主机 3306 docker run -d --name hospital-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=123456 -e MYSQL_DATABASE=hospital_db -v /path/to/mysql/data:/var/lib/mysql -d mysql:8.0

提示:/path/to/mysql/data替换成你本地一个真实存在的空文件夹路径,比如~/mysql-data。Docker 启动后等 10 秒,再执行下一步。

2.2 数据库初始化:别手动敲 SQL,用项目自带的 schema.sql 和 data.sql

项目根目录下有个sql/文件夹,里面就两份文件:schema.sql(建表+外键)和data.sql(插入默认科室、角色、测试用户)。很多人卡在这步,是因为直接双击.sql文件用 Navicat 执行,结果报错“Unknown database 'hospital_db'”。正确姿势是:先连上 MySQL,手动创建数据库,再导入。

# 进入 MySQL 客户端(密码是你 docker run 时设的) mysql -u root -p123456 # 在 MySQL 命令行里执行: CREATE DATABASE IF NOT EXISTS hospital_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE hospital_db; SOURCE /path/to/your/downloaded/project/sql/schema.sql; SOURCE /path/to/your/downloaded/project/sql/data.sql;

注意SOURCE命令里的路径必须是 MySQL 服务端能访问到的绝对路径(不是你本地下载路径)。如果你不熟命令行,就把schema.sql和data.sql全部内容复制,粘贴到 Navicat 的查询窗口里,分两次执行:先执行建表语句,再执行 INSERT 语句。千万别混在一起——data.sql里第一行是INSERT INTO department ...,如果表还没建,必然失败。

2.3 后端编译与启动:跳过 Maven 镜像源坑,用项目内置配置

项目用 Maven 管理依赖,pom.xml里已经锁定了 Spring Boot 2.7.18(注意不是 3.x,别自己升级)。关键点在于:不要全局修改你的settings.xml镜像源。很多同学改了阿里云镜像后,发现spring-boot-starter-web死活下载不下来——因为这个版本的某些依赖还在 jcenter 上,而阿里云镜像已停更。解决方案是:用项目根目录下的mvnw(Mac/Linux)或mvnw.cmd(Windows)脚本,它自带 wrapper,会自动下载对应版本的 Maven 并走默认中央仓库。

# 解压后进入项目根目录(含 pom.xml 的那个文件夹) cd /path/to/hospital-management-system # Linux/Mac 执行: ./mvnw clean package -DskipTests # Windows 执行: mvnw.cmd clean package -DskipTests # 成功后,target/ 目录下会出现 hospital-0.0.1-SNAPSHOT.jar java -jar target/hospital-0.0.1-SNAPSHOT.jar

启动日志里看到Tomcat started on port(s): 8080就算成功。此时浏览器打开http://localhost:8080,应该能看到登录页。默认账号密码在data.sql里写着:管理员admin/123456,护士nurse1/123456,医生doctor1/123456。

2.4 前端资源定位:静态页在哪?怎么改 logo?

这个项目是前后端分离的“伪分离”——前端 HTML/CSS/JS 全打在 jar 包里,放在src/main/resources/static/下。也就是说,你改完static/login.html,必须重新mvn clean package才生效。别指望热部署。所有页面都在这个目录下:login.html、index.html(主框架)、ward/bed-list.html(床位列表)、schedule/nurse-schedule.html(排班页)。Logo 图片在static/images/logo.png,替换它就行。CSS 主题色定义在static/css/style.css第 12 行:--primary-color: #1890ff;,改成#007bff就是 Bootstrap 蓝。改完记得清浏览器缓存(Ctrl+F5),否则 CSS 可能被强缓存。


3. 核心模块拆解:一个换床请求,如何穿过 Controller → Service → Mapper → DB 四层校验

3.1 请求入口:BedController.java里的changeBed()方法

换床是整个系统最复杂的业务动作,它暴露在/api/bed/change接口。我们从 Controller 层开始顺藤摸瓜。打开src/main/java/com/example/hospital/controller/BedController.java,找到第 87 行:

@PostMapping("/change") public Result changeBed(@RequestBody @Valid BedChangeRequest request) { boolean success = bedService.changeBed(request.getPatientId(), request.getOldBedId(), request.getNewBedId()); return success ? Result.success("换床成功") : Result.fail("换床失败,请检查床位状态"); }

注意三个关键点:

  1. @RequestBody表示参数从 JSON Body 读取,不是 URL 参数;
  2. @Valid触发BedChangeRequest类里的@NotNull、@Min(1)等注解校验;
  3. 返回值是封装好的Result对象,不是裸String或Map,前端统一处理.code和.message。

BedChangeRequest类在src/main/java/com/example/hospital/dto/下,只有三个字段,但每个都有业务含义:

public class BedChangeRequest { @NotNull(message = "患者ID不能为空") private Long patientId; @NotNull(message = "原床位ID不能为空") private Long oldBedId; // 必须是患者当前占用的床位 @NotNull(message = "新床位ID不能为空") @Min(value = 1, message = "新床位ID必须大于0") private Long newBedId; // 必须是空闲床位,且同科室 }

提示:@Min(1)是防 SQL 注入的底线校验,真正的业务规则在 Service 层。

3.2 业务中枢:BedService.java的事务边界与状态流转

真正干活的是BedService.changeBed()。打开src/main/java/com/example/hospital/service/impl/BedServiceImpl.java,核心逻辑在第 45 行:

@Transactional(rollbackFor = Exception.class) @Override public boolean changeBed(Long patientId, Long oldBedId, Long newBedId) { // 步骤1:查患者当前床位,确认 oldBedId 是否匹配(防越权) Patient patient = patientMapper.selectById(patientId); if (patient == null || !Objects.equals(patient.getCurrentBedId(), oldBedId)) { log.warn("患者{}尝试更换非当前占用床位{},oldBedId实际为{}", patientId, oldBedId, patient.getCurrentBedId()); return false; } // 步骤2:查新床位是否空闲且同科室 Bed newBed = bedMapper.selectById(newBedId); if (newBed == null || !newBed.getStatus().equals("FREE") || !newBed.getDepartmentId().equals(patient.getDepartmentId())) { log.warn("新床位{}状态非法:status={}, departmentId={}", newBedId, newBed == null ? "null" : newBed.getStatus(), newBed == null ? "null" : newBed.getDepartmentId()); return false; } // 步骤3:原子性更新——释放旧床 + 占用新床 + 更新患者记录 bedMapper.updateStatus(oldBedId, "FREE"); // 旧床变空闲 bedMapper.updateStatus(newBedId, "OCCUPIED"); // 新床变占用 patientMapper.updateCurrentBedId(patientId, newBedId); // 患者绑定新床 // 步骤4:写操作日志(可选,但项目里实现了) operationLogService.log("BED_CHANGE", patientId, "患者" + patient.getName() + "由" + oldBedId + "换至" + newBedId); return true; }

这段代码的精妙之处在于:

  • @Transactional保证四条 SQL 要么全成功,要么全回滚;
  • 每一步都带log.warn(),方便排查“为什么换不了床”;
  • updateStatus()是自定义的动态 SQL,在BedMapper.xml里,不是通用updateById,避免误更新其他字段。

3.3 数据持久:BedMapper.xml里的动态 SQL 与外键约束

BedMapper.xml在src/main/resources/mapper/下。updateStatus方法对应的 SQL 很短,但藏着设计哲学:

<!-- BedMapper.xml --> <update id="updateStatus" parameterType="map"> UPDATE bed SET status = #{status}, update_time = NOW() WHERE id = #{id} AND status != #{status} <!-- 防止重复更新 --> </update>

注意AND status != #{status}这个条件——它让 SQL 具备幂等性。就算前端手抖点了两次“换床”,第二次执行时WHERE条件不成立,UPDATE影响行数为 0,事务不会报错,只是静默跳过。这比在 Java 层加if (bed.getStatus().equals("FREE")) return;更可靠,因为数据库才是唯一真相。

再看建表语句schema.sql里bed表的定义:

CREATE TABLE `bed` ( `id` bigint NOT NULL AUTO_INCREMENT, `bed_number` varchar(20) NOT NULL COMMENT '床号,如A区101', `department_id` bigint NOT NULL COMMENT '所属科室ID', `status` enum('FREE','OCCUPIED','MAINTENANCE') DEFAULT 'FREE' COMMENT '状态', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_dept_status` (`department_id`,`status`), CONSTRAINT `fk_bed_department` FOREIGN KEY (`department_id`) REFERENCES `department` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

重点看CONSTRAINT fk_bed_department:科室删除时,该科室下所有床位自动级联删除。这符合现实——如果神经外科撤销了,它的床位自然不存在。但注意:ON DELETE CASCADE是双刃剑,生产环境慎用,课设里用它体现外键完整性。

3.4 前端联动:bed-list.html里的状态按钮如何响应后端返回

换床成功后,前端不能还显示“占用中”按钮。打开static/ward/bed-list.html,找到床位列表的<tbody>里渲染按钮的那段 JS(大概在第 156 行):

<td> <button class="btn btn-sm btn-outline-primary" onclick="changeBed({{bed.id}}, {{patient.id}}, {{bed.id}})" {% if bed.status == 'OCCUPIED' and patient %}disabled{% endif %}> {{ '占用中' if bed.status == 'OCCUPIED' else '空闲' }} </button> </td>

这里用了 Thymeleaf 模板语法({{ }}和{% %}),说明页面是服务端渲染的。changeBed()是一个全局 JS 函数,定义在static/js/bed.js里。它发 AJAX 请求后,成功回调里会刷新整张床位表:

// static/js/bed.js 第 42 行 function changeBed(oldBedId, patientId, newBedId) { $.post('/api/bed/change', { patientId: patientId, oldBedId: oldBedId, newBedId: newBedId }, function(res) { if (res.code === 200) { layer.msg(res.message, {icon: 1}); // 关键:重新加载床位表格,而非局部刷新 $('#bedTable').DataTable().ajax.reload(); } else { layer.msg(res.message, {icon: 2}); } }); }

DataTable().ajax.reload()是 jQuery DataTables 插件的方法,它会重新调用/api/bed/list接口拉取最新数据。这种“全量刷新”看似笨重,但胜在简单可靠——不用维护前端状态机,所有状态以数据库为准。


4. 避坑指南:那些让我重启三次 MySQL、重装两次 JDK 的血泪现场

4.1 现象:启动时报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver

原因:pom.xml里 MySQL 驱动版本是8.0.33,但你的本地 MySQL 是 5.7,驱动不兼容。com.mysql.cj.jdbc.Driver是 MySQL 8+ 的驱动类名,5.7 用的是com.mysql.jdbc.Driver。
解决:打开pom.xml,找到<artifactId>mysql-connector-java</artifactId>,将其<version>改为5.1.49,同时把Driver类名在application.properties里同步改掉:

# application.properties spring.datasource.driver-class-name=com.mysql.jdbc.Driver

改完mvn clean package重试。

4.2 现象:登录后跳转index.html,但页面空白,控制台报Uncaught ReferenceError: $ is not defined

原因:jQuery 没加载。index.html顶部<head>里引用了js/jquery.min.js,但路径错了——项目里实际文件在static/js/jquery-3.6.0.min.js,而 HTML 写的是js/jquery.min.js(少了个版本号)。
解决:打开static/index.html,找到第 12 行<script src="js/jquery.min.js"></script>,改成<script src="js/jquery-3.6.0.min.js"></script>。同理检查bootstrap.min.js和datatables.min.js的路径。

4.3 现象:排班页面拖拽后,松手没反应,Network 面板里/api/schedule/update返回 400

原因:ScheduleController.updateSchedule()方法接收@RequestBody ScheduleUpdateRequest,但前端传的 JSON 字段名是nurseId,而 DTO 里定义的是nurse_id(下划线命名),Jackson 默认不匹配。
解决:两种办法任选其一:

  • 方案 A(推荐):在ScheduleUpdateRequest.java的字段上加@JsonProperty("nurseId")注解;
  • 方案 B:在application.properties里加全局配置:spring.jackson.property-naming-strategy=LowerCamelCase。
    我选方案 A,因为粒度更细,不影响其他接口。

4.4 现象:新增患者时,选择科室下拉框为空,F12 看 Network 发现/api/department/list返回 500

原因:DepartmentController.list()方法里调用了departmentService.listAll(),而 Service 方法上漏写了@Override,导致它调用的是父类空实现。departmentService是BaseService<Department>的子类,但listAll()在父类里是return null;。
解决:打开src/main/java/com/example/hospital/service/impl/DepartmentServiceImpl.java,确保listAll()方法上有@Override注解,并且方法体是return departmentMapper.selectList(null);(MyBatis-Plus 的通用查询)。

4.5 现象:换床成功,但患者表里的current_bed_id没更新,查数据库发现还是旧值

原因:patientMapper.updateCurrentBedId()对应的 XML SQL 写错了。打开PatientMapper.xml,找到updateCurrentBedId标签,它的SET子句里写成了current_bed_id = #{bedId},但参数名是newBedId,MyBatis 绑定不上。
解决:把#{bedId}改成#{newBedId}。这种错误 IDE 不报红,只能靠日志和 SQL 日志排查。建议在application.properties里打开 MyBatis SQL 日志:

logging.level.com.example.hospital.mapper=DEBUG

这样控制台会打印出每条执行的 SQL 和参数值。


5. 二次开发实战:给排班模块加上“自动避让冲突”功能,三步搞定

5.1 需求分析:为什么手动拖拽不够?真实场景中的排班硬约束

某次陪护家属抱怨:“王护士昨天刚上完夜班,今天又被排早班,她路上差点晕倒。”——这暴露了原始排班模块的最大短板:它只做“谁在哪个班”,不做“谁不能在哪个班”。真实医院排班有三条铁律:

  1. 连续工作时长限制:同一护士 24 小时内累计工作不得超过 12 小时;
  2. 班次间隔限制:夜班(22:00-06:00)结束后,至少休息 16 小时才能上早班(08:00-16:00);
  3. 科室归属限制:心内科护士不能排到呼吸科班次。

原始系统只校验了第 3 条(通过nurse.department_id = schedule.department_id外键),前两条完全靠人工盯。我们要在保存排班前,插入一层智能校验。

5.2 接口改造:在ScheduleController里拦截save()请求,注入校验逻辑

打开src/main/java/com/example/hospital/controller/ScheduleController.java,找到save()方法(约第 62 行)。原始代码是直通scheduleService.save(schedule),我们要在前面加校验:

@PostMapping("/save") public Result save(@RequestBody @Valid Schedule schedule) { // 新增:调用冲突检测服务 ConflictResult conflict = conflictDetectionService.detect(schedule.getNurseId(), schedule.getShiftDate(), schedule.getShiftType()); if (!conflict.isSafe()) { return Result.fail("排班冲突:" + conflict.getMessage()); } boolean saved = scheduleService.save(schedule); return saved ? Result.success("排班保存成功") : Result.fail("保存失败"); }

ConflictResult是个简单 POJO,包含boolean safe和String message。关键在conflictDetectionService.detect(),它要查数据库判断风险。

5.3 核心算法:ConflictDetectionServiceImpl.java里的三重查询

新建src/main/java/com/example/hospital/service/impl/ConflictDetectionServiceImpl.java,实现detect()方法。它要做三件事,按顺序执行,任一失败即返回冲突:

@Service public class ConflictDetectionServiceImpl implements ConflictDetectionService { @Autowired private ScheduleMapper scheduleMapper; @Autowired private NurseMapper nurseMapper; @Override public ConflictResult detect(Long nurseId, LocalDate shiftDate, String shiftType) { // 步骤1:查该护士当天及前一天的所有排班,计算总工时 List<Schedule> schedules = scheduleMapper.selectByNurseAndDateRange(nurseId, shiftDate.minusDays(1), shiftDate); // 查 shiftDate-1 和 shiftDate 两天 long totalHours = schedules.stream() .mapToLong(s -> getShiftDuration(s.getShiftType())) // getShiftDuration() 返回 8/12/24 .sum(); if (totalHours > 12) { return new ConflictResult(false, "24小时内累计工时" + totalHours + "小时,超过12小时上限"); } // 步骤2:查最近一次夜班时间,判断与当前早班间隔 if ("MORNING".equals(shiftType)) { Schedule lastNight = scheduleMapper.selectLastNightShift(nurseId, shiftDate); if (lastNight != null) { LocalDateTime nightEnd = lastNight.getShiftDate().atTime(6, 0); // 夜班固定22-06 LocalDateTime morningStart = shiftDate.atTime(8, 0); long hoursBetween = ChronoUnit.HOURS.between(nightEnd, morningStart); if (hoursBetween < 16) { return new ConflictResult(false, "距上次夜班结束仅" + hoursBetween + "小时,不足16小时休息期"); } } } // 步骤3:科室校验(原始已有,此处复用) Nurse nurse = nurseMapper.selectById(nurseId); if (!Objects.equals(nurse.getDepartmentId(), scheduleMapper.selectDepartmentIdByDate(shiftDate))) { return new ConflictResult(false, "护士科室与排班科室不匹配"); } return new ConflictResult(true, "无冲突"); } private long getShiftDuration(String shiftType) { return switch (shiftType) { case "MORNING" -> 8; case "AFTERNOON" -> 8; case "NIGHT" -> 12; default -> 0; }; } }

注意selectByNurseAndDateRange等自定义方法,需要在ScheduleMapper.xml里补全 SQL。例如:

<!-- ScheduleMapper.xml --> <select id="selectByNurseAndDateRange" resultType="com.example.hospital.entity.Schedule"> SELECT * FROM schedule WHERE nurse_id = #{nurseId} AND shift_date BETWEEN #{startDate} AND #{endDate} </select>

5.4 前端增强:在排班页加一个“智能推荐”按钮,调用新接口预演

光有后端校验不够,护士长需要知道“如果我想排张护士上明天早班,系统会怎么拦我”。我们在static/schedule/nurse-schedule.html的排班表格上方,加一个按钮:

<button class="btn btn-info btn-sm" onclick="checkConflict()">🔍 智能冲突检测</button> <script> function checkConflict() { const nurseId = $('#nurseSelect').val(); // 假设有个护士下拉框 const date = $('#dateInput').val(); // 日期输入框 const shift = $('#shiftSelect').val(); // 班次下拉框 $.get('/api/conflict/detect', { nurseId: nurseId, shiftDate: date, shiftType: shift }, function(res) { if (res.code === 200) { layer.msg('✅ ' + res.data.message, {icon: 1}); } else { layer.msg('❌ ' + res.message, {icon: 2}); } }); } </script>

后端对应加个GET /api/conflict/detect接口,复用上面的detect()方法即可。这样,排班前就能看到红字警告,而不是保存时才被打断。

从那以后我每次接到课设类项目,都强制走一遍“三查”:查pom.xml里的 JDK 和依赖版本是否匹配本地环境;查application.properties里的数据库连接是否指向真实实例;查sql/下的初始化脚本是否被执行成功——这三步做完,90% 的启动失败都能当场定位。希望帮到你。

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