简介:基于SSM框架的Java毕业设计项目——养老院管理系统的完整源码包,面向计算机相关专业学生及Java开发者,可作为毕业设计参考或养老信息化系统的业务原型。系统采用B/S架构与MySQL数据库,后台管理与前台管理并行,按管理员、护工、患者家属三类角色分配权限:管理员统筹老人、房间、预定、退房、体检、饮食、事故、服药、请假、费用与评价等模块;家属端可查询房间信息、预约体检、在线请假、查看费用;护工端负责老人信息维护、饮食记录、健康信息及事故与服药管理。资源共1462个文件,以Java源码、JSP页面、JavaScript、CSS、图片及SQL脚本为主,包体约87.59MB,附带的说明文档与演示视频可帮助快速理解SSM框架整合、数据库设计及业务流程实现。已有176人学习,适合需要完整功能模块参考、准备毕业设计或进行二次开发的Java学习者。
1. 养老院管理系统的真实业务场景与选型背景
第一次拿到这套基于SSM框架的养老院管理系统源码时,我并没有急着看代码,而是先理了一下业务单据:家属要在线预约体检、护工要登记服药记录、管理员要处理退房和费用结算。这些操作涉及三个角色、两个端(后台管理和前台管理),背后是老人从入住到退房的完整生命周期。如果只是做个CRUD练手,用Spring Boot加MyBatis Generator就能快速跑通,但SSM这种分离式三层架构在事务控制、SQL调优和权限拦截上反而更“显性”,适合作为毕业设计展示完整工程能力。这套系统适合两类人:一是需要快速完成毕业设计并想讲清楚设计思路的学生,二是想接手或改造传统SSM项目的初级Java工程师。下文我会从数据模型、权限控制、业务流实现到部署验证,把核心代码和踩坑点逐一拆开。
2. SSM三层架构与数据模型设计:从ER图到MySQL建表
2.1 为什么用SSM而不是Spring Boot
SSM是Spring + Spring MVC + MyBatis的组合。和Spring Boot相比,它需要显式配置web.xml、spring-mvc.xml、mybatis-config.xml,上手成本高,但这也意味着你能看到Spring IoC容器如何组装Controller、Service、Mapper。在毕业答辩时,面试官常问的“Spring MVC请求流程”“MyBatis二级缓存”都能直接在工程里找到对应配置。我一般会建议保留SSM的XML配置,不要为了省事改成全注解,因为原始配置能更清晰地展示Bean的依赖关系。
2.2 核心表结构拆解:老人、房间、体检、费用的关系
从源码里的styles.css.bak和.jsp.bak能看出这是一个JSP + JSTL的老式项目,但数据库设计是完整的。我梳理出关系最重的几张表:
old_man(老人表):核心字段包括old_id、name、health_status、room_id、nurse_id、family_id,其中room_id关联房间表,family_id关联家属表。room_info(房间表):包含room_no、room_type、bed_count、room_status,状态值建议用0空闲 1占用 2维修,而不是直接删记录,方便后续统计入住率。check_record(体检表):一个老人多次体检,所以设计成从表,字段为old_id、check_date、result、doctor,每次体检生成一条记录。pay_record(费用表):包含project_name、amount、pay_time、old_id,费用项目如床位费、护理费、餐费,可以用type字段区分。
关键点在于:老人与房间是多对一,体检与老人是一对多,费用与老人也是一对多。这套模型的ER图可以画9张主表加6张关联表,足够支撑毕业设计里的E-R图章节。
2.3 MySQL建表DML及字段设计说明
这里我给出一个简化但可直接运行的建表脚本,重点在索引和外键的取舍:
CREATE TABLE `old_man` ( `old_id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL COMMENT '老人姓名', `gender` TINYINT DEFAULT 0 COMMENT '0男1女', `age` INT DEFAULT 0, `room_id` INT DEFAULT NULL COMMENT '当前入住房间', `family_id` INT DEFAULT NULL COMMENT '绑定家属账号', `nurse_id` INT DEFAULT NULL COMMENT '负责护工', `health_status` VARCHAR(255) DEFAULT NULL COMMENT '健康评估摘要', PRIMARY KEY (`old_id`), KEY `idx_room` (`room_id`), KEY `idx_family` (`family_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人基本信息表';注意几点:room_id我故意没有建外键,而是在Service层做业务校验。原因是在毕业设计演示时,如果删除房间被外键拦截,页面报错会打断演示节奏。用索引代替外键,可以保留性能,又能在ServiceImpl里抛出友好异常“该房间已被老人占用”。字段注释必须写全,用COMMENT描述每列含义,答辩时老师一眼能看到你的表设计是否规范。
2.4 数据权限的三种实现方式对比
养老院系统涉及家属、护工、管理员三种角色,如果不在数据层做隔离,家属登录后可能查到所有老人的体检记录。常见做法有三种:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 方案一:SQL拼接 | 在Mapper XML中用<if test="roleId==2">动态拼接where family_id = #{userId} | 灵活,针对性强 | 容易漏拼接,产生越权 |
| 方案二:MyBatis拦截器 | 实现Interceptor接口,自动追加数据权限SQL | 统一、不易漏 | 配置复杂,拦截器逻辑难排查 |
| 方案三:AOP切面 | 在Service方法上做注解,根据登录用户注入条件 | 代码侵入小 | 需要定义自定义注解,学习成本高 |
我建议毕业设计用方案一。原因很简单:答辩时你能直接从Mapper XML里翻出权限SQL讲给老师听,越权问题也最好复现。方案二和三虽然“高级”,但一旦拦截器顺序出错,所有分页查询都会受影响,调试成本太大。
3. 后台管理核心模块实战:基于Spring MVC的权限控制与业务流
3.1 Spring MVC拦截器实现登录与角色鉴权
系统后台管理包含家属管理、护工管理、老人管理、房间预定、退房管理等多个模块,每个URL都需要校验登录状态。配置一个HandlerInterceptor是最常规做法:
public class AuthInterceptor implements HandlerInterceptor { @Autowired private AdminService adminService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return false; } // 管理员路径要求角色为管理员 String uri = request.getRequestURI(); if (uri.contains("/admin/") && !"admin".equals(((User) user).getRole())) { response.sendError(403); return false; } return true; } }这段代码的逻辑是:先从Session取loginUser,为空就跳转登录页;如果请求路径含/admin/但当前用户角色不是admin,返回403。需要注意两点:一是response.sendRedirect后必须return false,否则请求会继续放行;二是不要把所有静态资源(css、js)也拦截,否则页面加载不出样式。我通常会在spring-mvc.xml里配置mvc:exclude-mapped排除/static/**和/login.jsp。
3.2 Service层事务控制:房间预定的并发防超卖
房间预定模块是后台管理中最容易出并发问题的模块。两个管理员同时给不同老人预定同一个房间,如果SQL只是简单的select count(*)再update,就会超卖。MyBatis配合Spring声明式事务可以这样处理:
@Transactional(rollbackFor = Exception.class) public boolean bookRoom(Integer roomId, Integer oldManId) { Room room = roomDao.selectRoomForUpdate(roomId); if (room == null || room.getRoomStatus() != 0) { throw new BusinessException("房间不可预定"); } roomDao.updateStatus(roomId, 1); roomBookDao.insert(new RoomBook(roomId, oldManId, new Date())); return true; }核心是selectRoomForUpdate这条SQL,它用的是SELECT ... FOR UPDATE行级锁:
SELECT * FROM room_info WHERE room_id = #{roomId} AND room_status = 0 FOR UPDATE;FOR UPDATE会锁住该行直到事务提交。另一个管理员同时执行这条SQL时会被阻塞,等第一个事务提交后才能读到最新状态。这里我要强调:@Transactional一定要写在Service方法上,而不是Controller或DAO上。因为FOR UPDATE的锁范围是整个事务,如果事务边界只包住DAO的select,锁会立即释放,防超卖就失效了。
3.3 服药管理与健康体检的状态机设计
服药管理模块和健康管理模块是护工主要操作的功能。我看了源码里的数据结构,发现服药记录其实是一个带状态的状态机:待服药 -> 已服药 -> 已漏服。如果用tinyint存状态,代码里写魔法数字,后期改状态判定会很痛苦。我自己实现时会用一个枚举类:
public enum MedicineStatus { PENDING(0, "待服药"), TAKEN(1, "已服药"), MISSED(2, "已漏服"); private final int value; private final String desc; MedicineStatus(int value, String desc) { this.value = value; this.desc = desc; } public static MedicineStatus fromValue(int value) { for (MedicineStatus status : MedicineStatus.values()) { if (status.value == value) { return status; } } throw new IllegalArgumentException("未知状态: " + value); } }在护工提交“已服药”操作时,Service层会校验当前状态必须是PENDING,避免从MISSED直接跳到TAKEN。体检结果模块也有类似逻辑:体检预约后,管理员填写结果,状态从已预约变为已完成。这种状态机驱动的写法,在答辩时可以讲成“业务状态流转设计”,比单纯贴CRUD代码有说服力得多。
3.4 后台管理界面的JSP复用与公共组件抽取
源码里的topNav.jsp.bak和setMenu.js.bak说明原项目用include方式复用页面片段。我在改造时会把所有JSP拆成header.jsp、left_menu.jsp、footer.jsp,再通过<jsp:include>引入。注意JSP中的<%@ include和<jsp:include>区别:前者是静态编译期合并,后者是运行时包含。如果片段里有变量需要动态设置,必须用<jsp:include>并传入参数:
<jsp:include page="/common/header.jsp"> <jsp:param name="menuActive" value="roomBook"/> </jsp:include>这样可以做到左侧菜单的高亮与当前模块联动。setMenu.js.bak里的逻辑其实就是根据menuActive给对应菜单项加class="active"。不要小看这个细节,答辩演示时菜单高亮能直观展示前后端传参方式。
4. 前台家属/护工端功能实现:查询、预约、记录与状态流转
4.1 家属端“查询房间信息”的MyBatis动态SQL
前台家属登录后,常见操作是查询空闲房间、预约体检、请假、查看费用。查询房间信息时,不同家属可能按房间类型、楼层、是否有阳台筛选,这时候MyBatis的<where>动态SQL就派上用场:
<select id="searchRooms" resultType="map"> SELECT r.room_id, r.room_no, r.room_type, r.bed_count, r.room_status, f.floor_name FROM room_info r LEFT JOIN floor_info f ON r.floor_id = f.floor_id <where> <if test="roomType != null and roomType != ''"> AND r.room_type = #{roomType} </if> <if test="floorId != null"> AND r.floor_id = #{floorId} </if> <if test="onlyFree != null and onlyFree == true"> AND r.room_status = 0 </if> </where> ORDER BY r.room_id </select><where>标签会自动去掉第一个AND,避免SQL语法错误。这里有个容易踩的坑:<if test="onlyFree != null">在MyBatis中,Boolean类型的字段如果为true,判断条件不能写成onlyFree,因为OGNL解析会有隐式转换问题。我在做查询时统一用onlyFree == true的写法。另外,LEFT JOIN floor_info是为了在前台页面展示“一楼、二楼”这种友好名称,关联字段floor_id最好在数据库加索引。
4.2 健康体检预约:时间冲突校验与Quartz/Spring Task的取舍
体检预约模块的业务规则是:一个老人在同一时间段不能有两条预约记录。如果不用数据库唯一索引,就得在代码里先查后插。我一般会在AppointService中加了这样一段逻辑:
public boolean createAppoint(AppointDTO dto) { Integer count = appointDao.countByOldManAndTime(dto.getOldId(), dto.getAppointTime()); if (count > 0) { throw new BusinessException("该时间段已有体检预约,请换个时间"); } // 剩余可预约名额 Integer used = appointDao.countByDoctorAndTime(dto.getDoctorId(), dto.getAppointTime()); if (used >= dto.getMaxCount()) { throw new BusinessException("该医生当前时段预约已满"); } appointDao.insert(dto); return true; }“预约提醒管理”模块原项目里应该有定时扫描当天预约数据的逻辑。我见过很多毕业设计在这里用Thread.sleep循环,这是绝对错误的做法。合理方案是使用Spring自带的@Scheduled注解:
@Service public class RemindTask { @Autowired private AppointDao appointDao; @Scheduled(cron = "0 0 8 * * ?") public void sendMorningRemind() { List<Appoint> list = appointDao.findTodayAppoint(); list.forEach(a -> { // 给家属发送短信或站内信,这里仅做日志 System.out.println("提醒老人:" + a.getOldName() + " 于 " + a.getAppointTime() + " 体检"); }); } }cron表达式0 0 8 * * ?表示每天8点执行。注意Spring Task的@Scheduled是单线程串行执行,如果任务耗时过长,会阻塞下一次执行。如果项目里还有“用药提醒”等定时任务,最好在spring-context.xml里配一个线程池,用@Scheduled(fixedDelay = 1000)配合@Async。对于毕业设计演示,@Scheduled已经足够,不需要引入Quartz增加复杂度。
4.3 留言板与公告浏览的列表分页性能优化
前台的公告和留言板都是高读取、低写入场景。用MyBatis分页时,我见过源码里直接写LIMIT #{offset}, #{pageSize}的,但这样每次都要count一次。更稳妥的办法是PageHelper插件,不过要注意PageHelper与Spring MVC的拦截器冲突问题。如果不想引插件,可以用Map传参:
<select id="selectNotices" resultType="Notice"> SELECT * FROM notice ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>Service层计算offset:
public PageResult<Notice> getNotices(int currentPage, int pageSize) { int offset = (currentPage - 1) * pageSize; List<Notice> list = noticeDao.selectNotices(offset, pageSize); int total = noticeDao.countAll(); return new PageResult<>(list, total, currentPage, pageSize); }这里的坑是:如果pageSize从页面传入,前端必须做数字类型校验。有些同学直接把字符串拼到SQL里,导致MyBatis报TypeException。还有一点,公告表要加create_time索引,否则数据量大时ORDER BY create_time DESC全表扫描,答辩演示时被老师点出来会很尴尬。
4.4 护工端的“事故管理”与“请假管理”的操作日志
护工可以新增事故记录、查看老人信息、登记饮食。事故管理比较敏感,最好把“谁在什么时间改了什么字段”记录下来。源码里没有日志表,我会补一个operate_log表,用AOP切面拦截写操作:
@Aspect @Component public class OperateLogAspect { @Autowired private OperateLogDao logDao; @Before("execution(* com.nursing.service.*.*(..)) && @annotation(opLog)") public void recordLog(JoinPoint joinPoint, OpLog opLog) { String methodName = joinPoint.getSignature().getName(); Object[] args = joinPoint.getArgs(); logDao.insert(new OperateLog(opLog.module(), methodName, args.length > 0 ? args[0].toString() : "")); } }这个切面的含义是:所有Service类中标注了@OpLog注解的方法,在执行前记录一条日志。需要注意,AOP代理默认使用JDK动态代理,如果Service类没有接口就会用CGLIB。SSM项目中Service通常都有接口,所以没问题。但要注意@Aspect切面自身不能被代理,否则会循环依赖。
5. 部署验证中的关键配置与常见坑
5.1 JDK 8 + Tomcat 8 的兼容性踩坑
这套SSM源码基于较老的Java Web规范,我建议使用JDK 8和Tomcat 8.5组合,千万不要用Tomcat 10。因为Tomcat 10把javax.servlet包迁移到了jakarta.servlet,老项目的web.xml和所有import javax.servlet.*会直接编译失败。配置Tomcat时,要确认JRE指向的是jdk1.8而不是系统默认的更高版本。如果在Eclipse或IDEA里启动时出现java.lang.UnsupportedClassVersionError,说明编译级别和运行环境不一致,把Project Structure里的Project SDK和Modules Language Level都改成8。
5.2 MySQL 5.7 与连接池参数
源码中的MySQL驱动如果是com.mysql.jdbc.Driver,在高版本MySQL(8.0以上)会报ClassNotFoundException。解决方案有两个:换用com.mysql.cj.jdbc.Driver并改Maven依赖,或者干脆沿用MySQL 5.7。数据库备份文件(.sql)导入时遇到Unknown collation: 'utf8mb4_0900_ai_ci',说明备份来自MySQL 8.0,导入5.7会失败。此时用文本编辑器把SQL里的utf8mb4_0900_ai_ci全部替换为utf8mb4_general_ci,再重新导入。连接池我习惯配druid,核心参数是initialSize=5、maxActive=20、minIdle=5。如果部署机器内存只有512MB,把maxActive降到10,否则启动时容易OOM。
5.3 数据库连接配置与编码问题的最终检查
jdbc.properties是一个高频出错点。我给出一个可用的模板:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/nursing_home?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456注意characterEncoding=utf8和serverTimezone=Asia/Shanghai缺一不可。如果后台页面中文乱码,但又确定这个配置没问题,就检查JSP页面头部的<%@ page contentType="text/html;charset=UTF-8"%>,还有web.xml中Spring MVC的CharacterEncodingFilter。这个Filter必须配置为第一个过滤器,并且forceEncoding设为true,否则POST请求中文参数仍会乱码。最后验证时,登录管理员账号,走一遍“家属管理 -> 添加家属 -> 家属重新登录 -> 预约体检 -> 护工登记服药 -> 管理员结算费用”这条主线,能跑通就说明所有表关联和状态流转都正常。
本文还有配套的精品资源,点击获取