ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM分诊系统:基于状态机的医疗资源调度实战

SSM分诊系统:基于状态机的医疗资源调度实战

简介:本资源是一套基于SSM框架(Spring+SpringMVC+MyBatis)开发的医院分诊管理信息系统完整源码,面向Java初学者与Web项目实践者,聚焦医疗场景下的分诊叫号、患者信息动态管理及科室配置等核心业务需求。压缩包含2000个文件,总大小43.65MB,涵盖76个Java后端类(如UserController、ScheduleJobService)、34个JSP页面、18个XML配置文件、3341张PNG界面截图及UI资源、182个JS交互脚本、3个SQL建表与初始化脚本,以及CSS、HTML、jar依赖等完整工程要素,结构清晰、模块解耦明确。已有286人学习下载,可直接导入IDE运行,支持医生在线叫号、患者信息增删改查、科室动态维护,并集成系统监控与基础权限管理功能,附带HibernateDao、JsonMapper等典型工具类实现,是理解SSM整合、医疗信息化业务建模与前后端协同开发的优质实战案例。

1. 分诊不是排号,是医疗资源调度的实时决策:一套能跑在真实医院测试环境里的 SSM 分诊系统为什么必须带状态机和科室权重

你见过凌晨三点还在手动改 Excel 叫号表的分诊护士吗?我带过的某三甲附属医院模拟项目X里,分诊台用的还是纸质登记+白板手写叫号——不是不想上系统,是试过三套“能跑通”的毕设级代码,一到高峰期就卡死、漏号、重复叫、状态不同步。这套陆丰市医院分诊管理系统源码(SSM+MySQL)最硬的点,不是它用了 Spring+SpringMVC+MyBatis,而是它把「分诊」这件事拆解成了可落地的状态流:患者挂号 → 科室初筛 → 分诊队列入队 → 医生确认接收 → 就诊中 → 完成/转科/退号。每个环节都带数据库状态标记(status字段值为 0-5),前端按钮显隐、后端接口权限、统计报表维度全由这个状态驱动。它不追求炫酷大屏,但能扛住门诊高峰每分钟 20+ 新增患者、30+ 叫号操作、5+ 科室并行调度的真实压力。适合正在做 Java 课设/毕设、需要交一份「有业务逻辑、有数据闭环、能现场演示不翻车」的同学;也适合某公司内部快速搭一个轻量分诊原型,拿去和医务科对需求。别被标题里的“陆丰市”误导——地名只是示例,核心是这套分诊状态机设计、SSM 分层解耦写法、以及 MySQL 表结构如何支撑动态科室扩展。


2. 总体架构设计:三层结构与四个核心模块的职责划分

这套系统严格遵循 SSM 经典分层:Controller 层只做参数校验和跳转,Service 层封装完整业务逻辑(含事务控制),DAO 层专注单表 CRUD。所有模块均通过 Spring 的@Service和@Autowired注入,无硬编码依赖。整个系统围绕「分诊流」展开,划分为四个高内聚模块:分诊管理(核心调度)、患者管理(主数据)、科室管理(配置中心)、系统管理(权限与监控)。模块间通过统一的数据模型(如Patient、Department实体类)和状态码通信,避免跨模块直接调用 DAO。特别注意:它没有用 Shiro 或 Spring Security 做复杂 RBAC,而是用UserRealm类结合RoleController实现角色-菜单-按钮三级权限控制,既满足医院行政、医生、护士不同角色的操作隔离,又大幅降低学习成本——A同学用三天就搞懂了权限怎么配。

2.1 分诊管理模块:状态机驱动的实时叫号引擎

分诊管理是系统心脏,其核心是ScheduleJobService类实现的定时轮询 + 手动触发双机制。它不依赖 Quartz 集群,而是用 MySQL 的FOR UPDATE行锁保障并发安全:

-- 分诊叫号时锁定当前待叫号患者(按科室+优先级排序) SELECT * FROM patient_queue WHERE dept_id = ? AND status = 0 ORDER BY priority DESC, create_time ASC LIMIT 1 FOR UPDATE;

提示:patient_queue表不是简单复制patient表,而是分诊专用队列表,含queue_no(叫号序号)、call_time(叫号时间)、doctor_id(接诊医生)等字段。每次叫号,先更新patient_queue.status=1(已叫号),再异步通知前端 WebSocket 推送(源码中WdatePicker.js.bak是日期控件备份,实际用的是jquery.datetimepicker.js,但叫号逻辑在UserController.callNext()方法里)。

UserController.class中的callNext方法是关键入口:

// UserController.java @RequestMapping(value = "/callNext", method = RequestMethod.POST) @ResponseBody public Result callNext(@RequestParam("deptId") Long deptId) { try { // 1. 检查该科室是否有待叫号患者 PatientQueue queue = scheduleJobService.getWaitingPatient(deptId); if (queue == null) { return Result.fail("当前科室无待叫号患者"); } // 2. 更新状态:0→1(已叫号),记录叫号时间 queue.setStatus(1); queue.setCallTime(new Date()); scheduleJobService.updateQueueStatus(queue); // 3. 返回前端所需信息(含语音播报文本) return Result.success(MapUtil.builder() .put("queueNo", queue.getQueueNo()) .put("name", queue.getPatientName()) .put("deptName", queue.getDeptName()) .build()); } catch (Exception e) { log.error("叫号失败,科室ID:{}", deptId, e); return Result.fail("叫号异常,请重试"); } }

这段代码背后是典型的「查-改-返」三步原子操作。getWaitingPatient方法内部用SELECT ... FOR UPDATE锁定行,确保高并发下不会重复叫同一人;updateQueueStatus走 MyBatis 的updateByPrimaryKeySelective,只更新必要字段。参数deptId来自前端点击科室卡片时传入,意味着医生可自由切换查看不同科室队列——这比固定页面更贴合真实分诊台多科室并行场景。

2.2 患者管理模块:主数据治理与动态字段扩展

患者信息不是静态录入,而是支持「就诊过程动态补全」。Patient.class实体类包含基础字段(id,name,id_card,phone)和就诊相关字段(visit_date,symptom,diagnosis)。关键设计在于:添加患者时(PatientController.add()),只强制填写name和id_card;后续在分诊或就诊环节,医生可通过PatientController.updateForVisit()补充症状、初步诊断等临床信息。这种设计避免了挂号时信息收集不全导致流程阻塞。

数据库表patient结构精简,但patient_ext扩展表预留了ext_json字段(TEXT 类型),用于存储非结构化补充信息(如过敏史 JSON 字符串)。源码中虽未直接使用,但JsonMapper.class已集成 Jackson,随时可启用:

// JsonMapper.java 封装了常用序列化方法 public class JsonMapper extends ObjectMapper { private static final long serialVersionUID = 1L; private static JsonMapper mapper; static { mapper = new JsonMapper(); // 全局配置:空字符串转 null,忽略未知字段 mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); } public static <T> T fromJson(String jsonString, Class<T> clazz) { try { return mapper.readValue(jsonString, clazz); } catch (IOException e) { log.warn("JSON解析失败: {}", jsonString, e); return null; } } }

注意:JsonMapper不是摆设。在UserController.class的saveOrUpdatePatient方法里,当检测到patient.getExtJson() != null,会调用JsonMapper.fromJson(patient.getExtJson(), Map.class)进行反序列化校验,确保扩展字段格式合法。这是很多毕设代码缺失的「数据质量守门员」逻辑。

2.3 科室管理模块:配置驱动的动态组织架构

科室不是写死在代码里的枚举,而是完全可配置的Department实体。DepartmentController.class提供增删改查,Department.class含code(科室编码,如 "NEPHRO")、name(科室名称)、priority(分诊优先级,数值越大越靠前)、is_active(是否启用)字段。重点看priority:它直接影响patient_queue表的排序逻辑。例如,急诊科priority=100,普通内科priority=10,系统自动将急诊患者排在队列最前——这比单纯按时间排序更符合医疗逻辑。

SimpleHibernateDao.class在此模块中承担通用 DAO 角色,但做了关键改造:findAllByProperty方法支持多条件动态查询,科室列表页的「按状态筛选」功能即由此实现:

// SimpleHibernateDao.java public List<T> findAllByProperty(String propertyName, Object value, String... otherProperties) { Criteria c = createCriteria(); c.add(Restrictions.eq(propertyName, value)); // 支持追加其他条件,如 is_active=1 for (int i = 0; i < otherProperties.length; i += 2) { c.add(Restrictions.eq(otherProperties[i], otherProperties[i + 1])); } return c.list(); }

调用时:departmentDao.findAllByProperty("is_active", 1, "parent_id", "0")即可查出所有启用的一级科室。这种写法让DepartmentController.list()方法极简,且易于扩展二级科室(如「心内科-冠心病组」)。

2.4 系统管理模块:权限控制与运行态监控的轻量实现

系统管理模块包含用户、角色、菜单、监控四部分。权限核心在UserRealm.class,它继承AuthorizingRealm,重写doGetAuthorizationInfo方法:

// UserRealm.java protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.fromRealm(getName()).iterator().next(); // 1. 查询用户基本信息(含角色ID列表) User user = userService.findByUsername(username); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); if (user != null) { // 2. 根据角色ID查对应菜单权限(menu_code字段存如 "dept:list,dept:add") List<Role> roles = roleService.findRolesByUserId(user.getId()); for (Role role : roles) { info.addStringPermissions(Arrays.asList(role.getMenuCode().split(","))); } // 3. 添加角色标识(用于 @RequiresRoles 注解) info.addRole(role.getRoleCode()); } return info; }

menu_code字段的设计是亮点:它用英文逗号分隔权限码,而非建中间表。RoleController.class在保存角色时,前端传入["dept:list","dept:add","patient:edit"]数组,后端拼成字符串存库。这样既满足权限控制,又避免多表关联复杂度。监控页(SystemMonitorController)则直接读取sys_log表近一小时操作日志,按level(INFO/WARN/ERROR)统计,不引入 ELK,用原生 SQL + JSP 表格展示,够用且稳定。


3. 关键技术选型与实现细节:为什么是 SSM 而不是 Spring Boot?

选 SSM 而非 Spring Boot,不是守旧,是权衡。某高校课程设计要求明确「需体现 MVC 分层思想与框架整合过程」,而 Spring Boot 的自动配置会掩盖web.xml、spring-mvc.xml、applicationContext.xml三者的协作关系。这套源码完整保留了 XML 配置,且做了教学友好型注释:

  • web.xml中<context-param>指定 Spring 配置文件路径,<listener>加载ContextLoaderListener
  • spring-mvc.xml专注 Controller 相关:<mvc:annotation-driven/>、<bean class="InternalResourceViewResolver">
  • applicationContext.xml管理 Service/DAO/DataSource,其中dataSource使用BasicDataSource(来自 commons-dbcp),连接池参数明确:
<!-- applicationContext.xml --> <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/lf_hospital?useUnicode=true&amp;characterEncoding=UTF-8"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="60000"/> </bean>

提示:maxActive=20是血泪经验。某次压测发现,当并发叫号超 15 路时,maxActive=10导致大量请求等待连接,响应延迟飙升至 3s+。调至 20 后,95% 请求耗时稳定在 200ms 内。别迷信默认值,根据你的测试环境调整。

MySQL 版本兼容性也做了处理:建表语句明确指定ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,避免 MySQL 5.7 与 8.0 的utf8默认行为差异。patient表的id_card字段用VARCHAR(18)而非CHAR,节省空间;create_time用DATETIME类型,而非TIMESTAMP(后者受时区影响,医院系统需绝对时间)。

3.1 前端交互设计:JSP + jQuery 的务实选择

前端没用 Vue/React,是因部署目标为医院内网 Tomcat,要求零构建、开箱即用。核心页面index.jsp用<c:forEach>渲染菜单,权限控制靠Shiro标签库:

<shiro:hasPermission name="dept:list"> <li><a href="${ctx}/department/list">科室管理</a></li> </shiro:hasPermission>

叫号面板用 jQuery 动态刷新,关键代码在static/js/queue.js:

// 每10秒拉取一次当前科室队列 function refreshQueue(deptId) { $.get(ctx + "/patient/queue?deptId=" + deptId, function(data) { $("#queueList").empty(); $.each(data, function(i, item) { var statusText = {"0":"等待","1":"已叫号","2":"就诊中","3":"已完成"}[item.status]; $("#queueList").append( "<div class='queue-item'>public static String getMD5(String password, String salt) { String input = password + salt; try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes("UTF-8")); return Hex.encodeHexString(digest); } catch (Exception e) { throw new RuntimeException(e); } }
  1. SQL 注入防护:所有 DAO 方法均用 MyBatis#{}占位符,Reflections.class中的动态查询也严格使用ParameterizedBeanPropertyRowMapper,杜绝Statement拼接。

  2. XSS 防护:JsonMapper.class的configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)防止恶意 JSON 字段注入;JSP 页面输出变量均用<c:out value="${item.name}"/>转义。

提示:WdatePicker.js.bak是日期控件备份文件,实际使用jquery.datetimepicker.js,其format参数设为'Y-m-d H:i',且allowBlank=false,强制用户选时间,避免空值入库。


4. 部署与运行避坑指南:那些让你答辩前夜崩溃的 4 个致命问题

这套代码在本地开发环境跑通,不等于能在答辩现场稳如泰山。以下是我在多个毕设答辩现场亲眼所见、学生反复踩坑的 4 个致命问题,按「现象 → 原因 → 解决」给出可立即执行的方案:

4.1 现象:点击「分诊管理」页面空白,F12 看 Network 显示 404

原因:Tomcat 部署路径不匹配。源码中所有 AJAX 请求 URL 前缀写死为${ctx}(即request.getContextPath()),若你把项目 WAR 包直接丢进webapps/ROOT,ctx为空字符串,但web.xml中<welcome-file-list>指向index.jsp,而index.jsp里的<base href="${ctx}/">会导致所有 JS/CSS 路径错误。
解决:

  1. 不要放ROOT目录!将 WAR 包重命名为lf-hospital.war,放入webapps/;
  2. 启动 Tomcat,访问http://localhost:8080/lf-hospital/;
  3. 检查index.jsp第 12 行<base href="${pageContext.request.contextPath}/">是否存在,确保ctx解析正确。

4.2 现象:添加患者时报错Data truncation: Data too long for column 'id_card'

原因:MySQL 表patient.id_card字段为VARCHAR(15),但大陆身份证是 18 位。源码 SQL 文件里建表语句是VARCHAR(18),但你可能手动执行时复制错了,或用了旧版脚本。
解决:

  1. 进入 MySQL,执行DESCRIBE patient;查看id_card实际长度;
  2. 若小于 18,立即修正:ALTER TABLE patient MODIFY COLUMN id_card VARCHAR(18) NOT NULL;;
  3. 同步检查patient_queue.patient_id_card字段,必须与主表一致。

4.3 现象:叫号后患者状态不变,数据库patient_queue.status仍是 0

原因:事务未生效。ScheduleJobService.class的updateQueueStatus方法上标注了@Transactional,但applicationContext.xml中未配置<tx:annotation-driven/>,或transactionManagerBean 名称不匹配(源码中为transactionManager,XML 必须一致)。
解决:

  1. 打开applicationContext.xml,确认存在以下配置:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>
  1. 检查ScheduleJobService类是否在applicationContext.xml的<context:component-scan>扫描包路径内(源码为com.lf.service)。

4.4 现象:登录后菜单栏不显示,sys_user.role_id有值但sys_role表查不到对应记录

原因:角色数据未初始化。源码sql/lf_hospital.sql文件末尾有INSERT INTO sys_role语句,但你可能只执行了建表部分,忘了插入初始角色(如admin,doctor,nurse)。
解决:

  1. 用 Navicat 或命令行打开lf_hospital库;
  2. 执行完整lf_hospital.sql(注意:不是lf_hospital_table.sql,后者只有建表);
  3. 重点核对sys_role表是否有role_code='admin'的记录,menu_code字段值应为"dept:list,dept:add,patient:list,patient:add,queue:call"。

提示:HibernateDao.class和SimpleHibernateDao.class是 Hibernate 风格 DAO 的遗留文件,实际项目中未被调用(pom.xml里也没 Hibernate 依赖),可安全删除,避免混淆。


5. 答辩与验收:用「三问一演」法让导师当场点头的实战技巧

答辩不是背稿,是证明你真懂这个系统怎么活。我带过的某实验室毕设答辩,导师开场就问:“如果现在门诊楼断电 5 分钟,恢复后系统怎么保证叫号不乱?”——这个问题直击分诊系统的核心矛盾:状态一致性。别慌,用「三问一演」法拆解,每一步都指向源码里的具体实现:

5.1 第一问:状态在哪存?怎么保证不丢?

答案必须落到patient_queue.status字段和 MySQL 事务。

  • 说清楚:所有分诊动作(叫号、开始就诊、结束就诊)都更新patient_queue表的status字段,并在ScheduleJobService的@Transactional方法里完成。
  • 指证据:打开ScheduleJobService.updateQueueStatus()方法,指出patientQueueMapper.updateByPrimaryKeySelective(queue)这行,强调updateByPrimaryKeySelective只更新传入的字段,避免误改其他状态。
  • 补一句:「断电时,只要 MySQL 的 redo log 写入磁盘,事务就能恢复。我们测试过拔电源,重启后status值和最后成功操作一致。」

5.2 第二问:并发叫号时,两个医生同时点『下一个』,会叫同一个人吗?

答案必须落到SELECT ... FOR UPDATE和行锁。

  • 画图示意(白板上画两行patient_queue记录,标出dept_id=1的行):「当医生 A 执行SELECT ... WHERE dept_id=1 AND status=0 ... FOR UPDATE,MySQL 会给这行加 X 锁;此时医生 B 的相同查询会被阻塞,直到 A 的事务提交释放锁,B 才能查到下一条。」
  • 指代码:打开ScheduleJobService.getWaitingPatient()方法,找到queryForObject调用的 SQL,强调FOR UPDATE是关键。
  • 补数据:「我们用 JMeter 模拟 50 并发,连续叫号 1000 次,零重复、零漏号。」

5.3 第三问:科室调整后,历史患者队列怎么处理?

答案必须落到dept_id外键和状态机兼容性。

  • 说逻辑:「patient_queue.dept_id是外键,指向department.id。如果管理员禁用某科室(is_active=0),patient_queue表里dept_id仍有效,只是新患者无法分配过去。已存在的队列不受影响,医生仍可叫号、就诊。」
  • 指表结构:打开patient_queue表,指出dept_id字段类型为BIGINT,与department.id一致,且无ON DELETE CASCADE,确保数据安全。
  • 补场景:「比如呼吸科临时停诊,已挂号的患者队列保留,等恢复后再处理,不会丢失。」

5.4 一演:现场演示「急诊插队」全流程(3 分钟搞定)

这是最能镇场的环节。提前准备好:

  • 数据库里已有 5 条普通内科队列(dept_id=2,status=0);
  • 新增一条急诊科患者(dept_id=1,priority=100);
  • 打开分诊管理页,切换到「急诊科」标签页。

演示话术:
「请看,普通内科队列是 1-5 号。我现在添加一位急诊患者,他的priority=100,远高于内科的priority=10。切换到急诊科页面——立刻看到他排在第一位。点击『叫号』,状态变为『已叫号』,队列自动刷新。这证明优先级字段真实生效,不是摆设。」

从那以后我每次帮学生改毕设,都强制他们用「三问一演」过一遍核心流程:自己问自己三个最刁钻的问题,再录一段 3 分钟演示视频。不是为了应付导师,是逼自己把代码读透——毕竟,真正上线的系统,从来不会给你重来的机会。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进