简介:一套基于SSM与MySQL的陆丰市医院分诊管理系统完整源码,面向需要学习Java Web项目开发或毕业设计的高校学生、初级程序员及医疗信息化爱好者。系统覆盖医生线上分诊叫号、患者信息在线编辑与统计、科室管理等核心功能,并附带系统管理与系统监控模块。分诊管理菜单下细分分诊管理、患者管理、科室管理三个子菜单:医生可查看当前患者情况、核对所选科室、确认是否完成就诊并在线叫号;患者管理支持新增患者,新增后自动出现在分诊列表;科室管理可灵活添加院内科室。整套代码包含SSM后端、前端页面与MySQL数据库,导入即可运行。资源包内共2000个文件,以png图片、js脚本、jar依赖、class编译结果、java源码、css样式、jsp页面和sql脚本为主,另有html、xml、gif等辅助文件,整体约43.65MB,目录结构清晰,便于分别查阅后端逻辑、前端界面与数据库初始化脚本。目前已有286人浏览学习。通过该项目可以熟悉SSM框架的整合方式、MyBatis操作MySQL的持久层写法,以及医院分诊场景下的业务流程与界面设计,既能作为课设或毕设的参考实现,也可在此基础上进行二次开发。
1. 医院分诊管理系统:一套能跑通线上叫号的 SSM 完整工程
先想一个场景:患者在前台登记完,护士拿着纸质单子走到诊室门口喊名字,医生在诊室里一边看诊一边还得记谁先来谁后来。这套源码解决的就是这个场景里的全部麻烦——它把分诊、叫号、患者信息维护、科室管理、系统权限收进同一个 Web 系统里,医生在电脑上点一下"叫号",大屏或诊室端就按顺序提示下一位患者。它适合两类人:一类是刚学完 SSM 想找个完整可运行项目练手的开发者,另一类是接医院信息化相关私活、需要一套能演示能二次改造的基础工程。整个系统由 Spring + SpringMVC + Shiro + MyBatis 组合搭成,前端是 JSP 加 jQuery 和 WdatePicker 日期控件,数据落在 MySQL,导入后直接能跑通,属于典型的 java ssm mysql 程序设计源码。
2. 技术栈与工程结构:SSM 后端、JSP 前端、MySQL 数据库的整体布局
2.1 技术选型:为什么这套工程还在用 SSM 而不是 Spring Boot
医疗信息化这类项目有一个特点:存量系统多、布点分散,院方长期维护的工程师更熟悉传统三层结构。所以这套项目没有赶 Spring Boot 的时髦,而是老老实实走 SSM 路线——Spring 负责 Bean 管理和事务,SpringMVC 负责路由和参数绑定,MyBatis 负责 SQL 映射,Shiro 负责登录认证与权限控制,Quartz 负责定时任务调度。这套组合在今天看起来不算新,但它有一个非常实际的好处:分层边界清楚,每个请求从 Controller 到 Service 再到 Mapper 是一条直线,debug 时追链路很舒服。
另外在看源码包时你会发现一个有意思的细节:包里有 HibernateDao.class 和 SimpleHibernateDao.class 这样 Hibernate 风格的工具类,同时又挂着 MyBatis 的 mapper 配置。这不是写错了,而是早期工程从 Hibernate 迁移到 MyBatis 时留下的历史遗留,属于"杂交"代码。阅读时碰到这两个类不要困惑,它们不参与主业务流,只是被少部分老接口引用着,改造时可以逐步摘掉。
2.2 源码包里的类文件:哪些是框架代码,哪些是业务代码
拿到压缩包后先别急着导入,打开看一圈 class 文件,能大致判断出工程里各个模块的职责。我把正文里出现的类文件按功能归了一下类:
| 类文件 | 职责 | 归属模块 |
|---|---|---|
| User.class / UserController.class | 用户实体与用户管理接口 | 系统管理 |
| RoleController.class | 角色管理与授权接口 | 系统管理 |
| UserRealm.class | Shiro 的认证与授权核心实现 | 权限框架 |
| JsonMapper.class | JSON 序列化与反序列化工具 | 公共工具 |
| Reflections.class | 反射扫描工具,常用于权限注解扫描 | 公共工具 |
| HibernateDao.class / SimpleHibernateDao.class | 老版 DAO 封装 | 历史遗留 |
| ScheduleJobService.class | 定时任务服务 | 系统监控 |
| WdatePicker.js.bak | 日期控件备份文件 | 前端资源 |
其中 UserRealm.class 是理解整个权限链路的关键。Shiro 在拦截到请求后,会调用 UserRealm 里的 doGetAuthenticationInfo 做登录认证,再通过 doGetAuthorizationInfo 加载当前用户的角色和权限码。这套工程里权限直接落在角色上,通过 sys_user、sys_role、sys_user_role 三张表关联,改动权限只需要往角色上挂权限码,不用碰代码。
2.3 目录结构:SSM 工程里包和资源的常见组织方式
我一般会把这类工程导入后先拉一遍包结构,确认 Controller、Service、Mapper 的分层位置,再去找前端页面。典型的组织方式是:
src/main/java ├── com.hospital.triage │ ├── controller # 分诊管理、患者管理、科室管理、用户管理 │ │ ├── TriageController.java │ │ ├── PatientController.java │ │ └── DeptController.java │ ├── service # 业务接口与实现 │ ├── dao # MyBatis Mapper 接口 │ ├── entity # 患者、科室、分诊记录、用户实体 │ ├── mapper # Mapper XML 映射文件 │ ├── shiro # UserRealm、SessionManager │ ├── util # JsonMapper、Reflections 等工具 │ └── model # 返回结果封装、分页对象 src/main/resources ├── spring-context.xml # 数据源、事务、MyBatis 配置 ├── spring-mvc.xml # 注解驱动、视图解析 └── mybatis-mapper/ # 各表 SQL 映射 src/main/webapp ├── WEB-INF ├── views # JSP 页面 ├── static # JS、CSS、WdatePicker 组件看目录时注意 controller 包里每个类只对应一个业务模块,不要在里面堆公共逻辑。比如 PatientController 只处理患者增删改查,TriageController 只处理分诊和叫号,这样后面做二次开发时,加一个接口只需要动一个 Controller、一个 Service、一个 Mapper,不牵扯其他模块。
2.4 运行环境与准备
跑这套工程不需要很新的环境,我建议用 JDK 1.8 + Maven 3.6 + Tomcat 8.5 + MySQL 5.7 这套组合。在 IDEA 里导入后先让 Maven 把依赖拉全,然后改数据库连接,最后部署 Tomcat 启动。注意项目是 Maven 多模块还是单模块要看具体目录,这个工程是单模块结构,导入方式选"Import as Maven Project"即可。编码统一设成 UTF-8,否则 JSP 页面中文会乱码。
3. 核心模块拆解:从患者建档到分诊叫号的完整业务链路
3.1 患者管理:先有患者,才能分诊
整个系统的业务起点是患者建档。流程上,护士先在患者管理页面点"添加",录好姓名、性别、年龄、联系电话、身份证号等信息,保存后这条患者记录进入患者表。分诊管理页面读取的是同一张患者表,所以新患者保存后立刻能在待分诊列表里看到,这就是摘要里说的"添加完毕后,在分诊管理里就能看到该患者"。
患者添加接口的 Controller 写法很典型:
@Controller @RequestMapping("/patient") public class PatientController { @Autowired private PatientService patientService; @RequestMapping(value = "/add", method = RequestMethod.POST) @ResponseBody public String addPatient(@RequestBody Patient patient) { // 新增前校验身份证号是否已存在,避免重复建档 if (patientService.checkIdCardExists(patient.getIdCard())) { return "该身份证号已存在,请勿重复添加"; } patient.setCreateTime(new Date()); patient.setStatus(0); // 0表示待分诊,1表示已分诊,2表示已完成 int result = patientService.insertPatient(patient); return result > 0 ? "success" : "failed"; } }这段代码里最关键的是 status 字段,它承担了患者整个就诊流程的状态标记。0 是刚建档还没分诊,1 是护士选了科室完成分诊,2 是医生叫号并看诊结束。后边的分诊管理列表和叫号操作全部依赖这个字段做筛选和流转,所以二次开发时如果要加"已叫号未就诊"这类中间状态,直接在这个字段上扩展即可。
Service 层加接口做的事情很标准:先判重,再补全创建时间和默认状态,然后走 MyBatis 的 insert 落库。事务注解 @Transactional 加在 Service 实现类上,保证 insert 失败时不会留下半截数据。我没看到源码里具体的事务配置方式,但按这类工程的惯例,会在 spring-context.xml 里统一开启注解驱动事务管理,切面点在 service 包下。
3.2 分诊管理:科室绑定、状态流转与在线叫号
分诊管理是整个系统最核心的模块,它同时承担三件事:展示待分诊患者列表、为患者指定科室、医生在线叫号。
分诊列表的查询逻辑是多表联查,患者表关联科室表,把科室名称和当前状态一起查出来:
@RequestMapping(value = "/triage/list", method = RequestMethod.GET) @ResponseBody public Map<String, Object> triageList(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int limit) { Map<String, Object> result = new HashMap<>(); // 底层按 status < 2 过滤,只显示待分诊和已分诊但未完成的患者 List<TriageVO> list = triageService.queryTriagePage(page, limit); int total = triageService.countTriage(); result.put("code", 0); result.put("count", total); result.put("data", list); return result; }这里返回的 TriageVO 是一个视图对象,里面包含患者姓名、选择的科室、分诊时间、是否完成就诊等展示字段。它和实体类的区别在于 VO 是给前端页面定制的聚合对象,不会把数据库所有字段都暴露出去。列表查询的过滤条件也值得注意:状态不是只看 0,而是把 0 和 1 都捞出来,保证已经分诊还没看诊的患者继续留在列表里,方便医生按顺序叫号。
叫号动作本质上是把患者状态从 1 改成 2,同时记录叫号时间。实现上是一个简单的状态更新,但业务含义明确——哪个医生在什么时间叫了哪个患者,后续统计模块全靠这条记录:
@RequestMapping(value = "/call", method = RequestMethod.POST) @ResponseBody public String callNext(@RequestParam int triageId) { // 加锁防止两个医生同时对同一个患者叫号 boolean locked = triageService.lockPatient(triageId); if (!locked) { return "该患者已被其他医生叫号"; } TriageRecord record = new TriageRecord(); record.setTriageId(triageId); record.setCallTime(new Date()); // 更新患者状态为已完成,同时写一条分诊记录 triageService.updateTriageStatus(triageId, 2); triageService.insertCallRecord(record); return "请患者携带就诊卡到 3 号诊室"; }叫号这个操作在并发场景下容易出问题,两个医生同时对一个患者叫号,会出现"一人被叫两次"的冲突。所以源码里专门做了 lockPatient 这一步,用数据库行锁或者 Redis 分布式锁把患者记录锁住,后到的请求直接返回冲突提示。我自己的项目里一般用乐观锁解决,给患者表加一个 version 字段,更新时带上版本号,更新影响行数为 0 就说明被别人抢了。这套源码具体用哪种锁机制可以看 service 实现,但思路是一致的——叫号必须先锁后更。
3.3 科室管理:分诊的前置配置
科室管理的功能相对简单,就是维护医院里一共有哪些科室,分诊时下拉框的数据源从这里来。一个科室字段包含科室编号、科室名称、所属楼层、是否启用等信息。新增科室的逻辑和患者新增类似,但多一个停用功能。我平时会建议在科室表里加 is_enabled 字段,停用的科室在分诊时不再出现在下拉框里,但不会影响历史分诊记录的显示,避免老数据变成"无科室"状态。
4. 数据库设计与 Mapper 层:分诊记录如何联动患者和科室
4.1 核心表结构
整个系统最核心的表是患者表、科室表和分诊记录表。建表语句如下,三张表通过外键逻辑关联,分诊记录表同时引用患者和科室:
CREATE TABLE `t_patient` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `id_card` VARCHAR(18) DEFAULT NULL COMMENT '身份证号', `patient_name` VARCHAR(50) NOT NULL COMMENT '患者姓名', `gender` TINYINT DEFAULT 0 COMMENT '0男 1女', `age` INT DEFAULT NULL COMMENT '年龄', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `status` TINYINT DEFAULT 0 COMMENT '0待分诊 1已分诊 2已完成', `create_time` DATETIME DEFAULT NULL COMMENT '建档时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_id_card` (`id_card`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='患者信息表'; CREATE TABLE `t_department` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `dept_code` VARCHAR(20) NOT NULL COMMENT '科室编号', `dept_name` VARCHAR(50) NOT NULL COMMENT '科室名称', `floor` VARCHAR(20) DEFAULT NULL COMMENT '所在楼层', `is_enabled` TINYINT DEFAULT 1 COMMENT '1启用 0停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科室表'; CREATE TABLE `t_triage_record` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `patient_id` INT NOT NULL COMMENT '患者ID', `dept_id` INT NOT NULL COMMENT '科室ID', `dept_name` VARCHAR(50) DEFAULT NULL COMMENT '科室名称快照', `call_time` DATETIME DEFAULT NULL COMMENT '叫号时间', `status` TINYINT DEFAULT 1 COMMENT '分诊状态', PRIMARY KEY (`id`), KEY `idx_patient_id` (`patient_id`), KEY `idx_dept_id` (`dept_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分诊记录表';这里有两个设计上的细节值得展开。一个是分诊记录表里专门存了 dept_name 快照字段,为什么不直接靠 dept_id 关联科室表拿名字?因为如果科室以后改了名字,历史分诊记录里的科室名也会跟着变,统计报表里"这个科室当时接了多少人"就没法看了。快照字段在业务表里属于典型的空间换时间、换稳定性做法。
另一个是患者表身份证号加了唯一索引。医院场景下一个人只能建一次档,重复建档会直接导致分诊列表里出现两个一样的患者,所以这个唯一约束在数据库层面做掉了。
4.2 关键 SQL:分诊列表联查与叫号更新
Mapper XML 里的 SQL 最能看出一个系统的真实水平。分诊列表用的联查思路是主表在分诊记录上,左右关联患者和科室,而不是反向去患者表里查:
<select id="queryTriagePage" resultType="com.hospital.triage.model.vo.TriageVO"> SELECT r.id AS triageId, p.patient_name AS patientName, p.gender AS gender, p.age AS age, r.dept_name AS deptName, r.call_time AS callTime, r.status AS status FROM t_triage_record r LEFT JOIN t_patient p ON r.patient_id = p.id WHERE r.status < 2 ORDER BY CASE WHEN r.status = 0 THEN 0 ELSE 1 END, r.id ASC LIMIT #{offset}, #{limit} </select>这段 SQL 有几个值得抄的地方。ORDER BY 里用 CASE WHEN 做排序优先级,把还没分诊的记录排前面,已经分诊在排队的排后面;同状态下再按 id 升序,天然就是按建档先后顺序排队,等于不用额外维护一个队列字段。LIMIT #{offset}, #{limit} 是 MyBatis 里手写分页的最常见写法,offset 从 0 开始,limit 是每页条数。
叫号更新的 SQL 更简单,但要特别注意 WHERE 条件必须带上旧状态:
<update id="updateTriageStatus"> UPDATE t_triage_record SET status = #{newStatus}, call_time = NOW() WHERE id = #{triageId} AND status = #{oldStatus} </update>WHERE 里带 status = #{oldStatus} 是个好习惯,它把状态更新的前置条件放到了 SQL 层,更新影响行数为 0 时说明状态已经被别人改过,Service 层再决定要不要报冲突。这比先查一次再更新要稳得多,天然防并发覆盖。
4.3 用户与权限的表设计
系统管理模块涉及用户和角色,表结构是三张表:sys_user 存账号密码、sys_role 存角色名、sys_user_role 存关联关系。密码字段的存储方式是 Shiro 框架的典型做法——不加盐的 MD5 或者加盐 MD5 取决于源码里 UserRealm 的匹配器配置,我见过的大部分 SSM 老工程用的是 MD5 加盐,盐值就是用户名。这套源码具体用的哪种可以在数据库里看密码字段的格式判断:32 位纯 MD5 是不加盐,带长随机串的是加盐。
sys_user_role 关联表还隐含了一个权限细节——角色是扁平的,没有角色层级。这意味着一个用户如果想同时拥有"分诊护士"和"医生"两种角色的权限,就必须在关联表里配两条记录。UserRealm 在授权时会把多个角色的权限码取并集。这种设计简单粗暴,但确实够用,医院内部角色数量通常不超过十来个。
5. 跑通与排查:从导入源码到首次叫号的 5 个踩坑记录
这套源码整体不算复杂,但跑起来的过程中有几个坑是几乎每个导入者都会撞到的,我按"现象 → 原因 → 解决"的顺序列出来,能少走不少弯路。
5.1 JSP 页面 404:视图解析器前缀配置不一致
现象:Tomcat 启动不报错,登录页能开,但点进分诊管理菜单后页面 404。
原因:spring-mvc.xml 里 InternalResourceViewResolver 配置的 prefix 是 /WEB-INF/views/,但实际 JSP 文件被放在了 /views/ 目录下,前后缀对不上,SpringMVC 找不到视图。
解决:把 JSP 文件统一挪到 /WEB-INF/views/ 下面,或者改 prefix 指向实际目录。我一般习惯统一放到 WEB-INF 下,因为 WEB-INF 目录默认不能通过 URL 直接访问,相当于给页面加了一层防直接访问的保险。
5.2 登录界面无响应:Shiro 拦截路径把静态资源拦了
现象:页面样式全丢,登录请求发出去一直转圈,控制台看到 /static 路径的请求全部被拦截。
原因:Shiro 的过滤器链里把 /** 直接设置成 authc,静态资源也被拦在里面,未认证请求拿不到 CSS 和 JS。
解决:在 ShiroConfig 里放行静态资源,配置顺序很关键,anon 规则必须写在 authc 前面:
Map<String, String> filterChain = new LinkedHashMap<>(); filterChain.put("/static/**", "anon"); // 静态资源匿名访问 filterChain.put("/css/**", "anon"); filterChain.put("/js/**", "anon"); filterChain.put("/login", "anon"); filterChain.put("/**", "authc"); // 其余请求需要登录5.3 分诊列表查不出数据:status 字段的初始值没写对
现象:患者添加成功后,分诊管理页面列表是空的,数据库里明明有记录。
原因:添加患者接口没给 status 赋值,而数据库字段默认值设置了 DEFAULT 0,在 MyBatis 的 insert 语句没写这个字段时,新插入的记录 status 是 NULL,而列表查询条件是 status < 2,NULL 参与比较永远不成立。
解决:在实体类里给 status 初始化成 0,或者在 insert SQL 里显式写上 status 字段。这两种方式我推荐前者,因为实体类初始化在单元测试里更容易验证。
5.4 日期控件不加载:WdatePicker 资源路径带上了 bak 后缀
现象:患者管理页面的出生日期输入框点击没反应,页面报 WdatePicker.js 找不到。
原因:源码包提供的是 WdatePicker.js.bak 这个备份文件,原开发者在替换日期控件的时候把原文件改了后缀,但页面 script 引用的还是 WdatePicker.js。
解决:复制一份 WdatePicker.js.bak 改名为 WdatePicker.js,放到同一目录下。这个坑每次导入都会遇到,我一般第一步就先检查 WebContent 下的 js/ 目录有没有这个问题。
5.5 叫号后状态没变化:Service 事务回滚把更新吞了
现象:点击叫号按钮提示成功,但刷新后分诊记录状态还是 1,没变成 2。
原因:叫号方法里先调用了 lockPatient,这个方法内部开了新事务做了行锁;紧接着的 updateTriageStatus 在同一个事务上下文里,但因为 lockPatient 的事务已经提交,外层事务把它当成只读,状态变更没有真正提交。
解决:做法是把叫号相关的两步合并到一个事务方法里,或者把锁提前到入口处,避免嵌套事务。这也是我在前面代码示例里把 lock 和 update 分开写但强调要合并的原因——实际项目里这种状态更新被嵌套事务吞掉的情况太常见了,排查起来还特别隐蔽。
6. 二次开发思路:给分诊系统加多诊室队列与统计报表
拿到这套系统并跑通之后,多数人的需求不只是看效果,而是要往里加东西。我挑两个最有代表性的改造点说一下思路。
第一个改造是单队列变多诊室。现在系统里一个科室对应一个队列,叫号顺序是全科统一排。但现实中一个科室有好几个诊室,每个医生各排一队。改造的核心是把 t_triage_record 表加一个 doctor_id 字段,分诊时下拉选择科室后再选接诊医生;叫号列表按 doctor_id 过滤,每个医生只看到分配给自己的患者。SQL 上只需要把查询条件从 WHERE dept_id = ? 改成 WHERE doctor_id = ?,再按 call_time 排序。这个改动涉及 Controller 加一个参数、Mapper 加一个条件、前端分诊页面加一个医生下拉框,整体工作量不大,但对业务价值的提升非常明显。
第二个改造是就诊统计报表。现在的分诊记录包含叫号时间,但缺少患者从分诊到叫号的时间差计算。如果要做一个"每科室平均候诊时间"的报表,在查询时把 wait_minutes 字段在 SQL 里直接算出来:
SELECT dept_name, COUNT(*) AS total_count, ROUND(AVG(TIMESTAMPDIFF(MINUTE, create_time, call_time)), 1) AS avg_wait_minutes FROM t_triage_record WHERE call_time IS NOT NULL GROUP BY dept_name有了这个数据,可以进一步做科室负荷分析、高峰时段分布,甚至可以做一个简单的趋势图页面。
这套工程我前后拆了两遍,第一遍只顾着把功能跑通没注意事务和锁的问题,第二遍才真正理解它状态流转的设计。从那以后我不管接手哪套 SSM 工程,都强制自己先画一张"状态流转图 + 表关系图"再动代码,因为这类老工程真正的复杂度从来不在单点功能上,而在状态与状态之间的边界里。希望帮到你。
本文还有配套的精品资源,点击获取