简介:一款采用Java语言实现的养老院管理系统源码,定位为中小型养老机构的信息化管理方案,适合Java初学者、毕业设计者以及需要快速理解Web项目结构的开发者。系统围绕老人档案、健康监测、护理记录、服务预约、员工管理、财务统计等核心业务进行模块化设计,代码分层清晰,便于理解业务逻辑与数据流转。压缩包包含一百五十五个文件,其中一百三十三个Java源文件承担主要业务逻辑、接口封装与界面交互,十七个XML配置文件用于管理数据源、服务端口、自动填充等运行参数,另有少量JSON、YAML和忽略规则文件辅助项目配置,整体大小约四百二十KB,内容紧凑。已有109人浏览学习,适合用来研究JavaWeb项目的包结构、通用工具类、分页响应、拦截器及配置类等常见实现。通过研读源码,可掌握一套完整的管理系统开发思路,并可根据实际需求对模块进行扩展与二次开发。
1. 基于Java的养老院管理系统源码,先想清楚它到底解决什么问题
“基于Java语言的养老院管理系统设计源码”这类项目,是Java课程设计和毕业设计里出现频率极高的选题,也是不少小型养老机构想低成本做内部信息化时第一个搜的方向。它要解决的核心问题很具体:老人档案怎么管、床位谁在住、费用怎么按月结算、退住时账怎么算清楚,外加护工排班和健康记录这些日常事务。
大多数从业者第一次看到这套源码,都会误以为难点在增删改查上。真正上手后才发现,入住退住的状态流转、床位并发分配、金额精度、逻辑删除与唯一索引的冲突,才是把人卡住的地方。这套系统适合两类人:一类是拿它做Java课程设计、需要完整代码和数据库设计去答辩的学生;另一类是想低成本搭建机构管理后台、后续打算二次开发的开发者和运维。目录和代码我会按Spring Boot + MyBatis-Plus + MySQL这套最常规的Java技术线走下去,先把道理讲明白,再给能直接抄的源码。
2. 技术选型与源码包结构:Spring Boot + MyBatis-Plus为什么是默认答案
2.1 技术栈怎么定:Spring Boot单体、MyBatis-Plus与MySQL的取舍
养老院管理系统的业务量级,一个中小型机构撑死几千位老人、几十个护工、每月几千张账单,并发通常就是个位数。常见做法是直接上Spring Boot单体应用,内嵌Tomcat,打成jar包一键启动,运维成本低。有人会提Servlet + JSP的老方案,课程设计里确实还有,但JSP的页面逻辑和Java代码混在一起,后续加个权限拦截都要四处改,我不建议新项目再走这条路。
持久层我用MyBatis-Plus而不是原生MyBatis。这类管理系统的SQL大量是“按条件查列表、改状态、逻辑删除”,BaseMapper直接给了一套现成的CRUD,分页用内置的Page,逻辑删除只需要配置一个字段。原生MyBatis当然更灵活,但菜单管理、角色权限、老人列表这些页面,写XML去拼动态SQL有点重复劳动。至于为什么是MySQL而不是PostgreSQL,主要原因是课程设计和机构现有环境里MySQL占绝大多数,网上能查到的排错经验也最多,团队接手成本最低。
前端部分,源码里常见做法是Thymeleaf服务端渲染加一套AdminLTE或Bootstrap模板,后端写Controller返回视图名。这套方案在课程设计里最稳,不用单独起Node服务,答辩演示时不容易翻车。如果做前后端分离,Spring Boot只出JSON接口,前端用Vue,工程上更现代,但部署链路多一层。我的建议是:以源码交付和二次开发为目标,选Thymeleaf单体,逻辑闭环最短;如果本来就熟悉Vue,再改REST接口也不迟。
2.2 源码工程怎么分层:按层分包够用,别一上来就微服务
源码包结构我一般按层分包,虽然简单,但对这个体量的系统完全够用,新人也容易看懂。controller负责参数接收和返回,service写业务规则,mapper管数据库操作,entity对应表结构,dto放前端传入的对象,config放MyBatis-Plus和拦截器配置,common放统一返回结构和异常处理。
| 包名 | 职责 | 典型类 |
|---|---|---|
| controller | HTTP入口、参数校验、调用service | ElderController |
| service | 业务规则、事务控制 | ElderService、FeeService |
| mapper | 数据库操作,继承BaseMapper | ElderMapper |
| entity | 表实体,字段与列一一对应 | Elder、Bed、FeeBill |
| dto | 入参封装,避免直接暴露实体 | CheckInDTO |
| config | 全局配置 | MybatisPlusConfig、WebMvcConfig |
| common | 统一返回、异常、工具类 | Result、BizException |
有人会把按模块分包挂在嘴边,比如elder包下面再放controller/service/mapper。对单体课程设计来说,这会让目录层数变多,改动一个功能要跨多个大包跳转,反而增加了理解成本。等系统大到几十个模块再重构不迟,现在优先保证“打开源码就能顺着包名找到代码”。
Maven依赖里最需要注意的是Spring Boot版本和MyBatis-Plus版本的兼容性。常见的坑是Spring Boot 3.x配了旧版MyBatis-Plus,启动直接报错。我习惯Spring Boot 2.7.x配MyBatis-Plus 3.5.x,JDK用1.8,这套组合网上资料最多,遇到问题一搜就有答案。数据库连接池选Druid,监控页面能看到SQL执行情况,排查慢查询比HikariCP更直观,课程设计里写“集成Druid监控”也是一个加分项。
依赖文件里还有一个小细节:thymeleaf-extras-java8time,老项目里LocalDate在前端格式化要靠它。新一点的Spring Boot版本已经内置,但如果你的源码是从某个旧仓库拉下来的,启动后发现日期显示成时间戳,第一反应先检查这个依赖在不在。这种问题日志里不会报错,只在页面上表现异常,最容易被当成“玄学”。
3. 数据库设计:从入住到退住,三张核心表串起全部业务
3.1 用业务流程反推表结构:老人、床位、账单谁先谁后
设计数据库之前,先把养老院的业务链路走一遍。老人入住时,前台登记身份信息,同时分配一张空闲床位,收取押金,然后安排护工;住进来之后,每个月按床位费和护理费生成账单,记录缴费;老人退住时,结算未缴费用、退还押金、释放床位。这个过程里,老人信息、床位状态、费用账单是三个最核心的实体。
表的设计顺序是:先建床位表,因为床位是资源,状态独立于老人存在;再建老人表,通过bed_id关联床位;最后建账单表,以老人为维度按月生成记录。护工、健康记录、家属访客这些属于扩展表,第一版可以先不做,但老人表和账单表必须一次设计到位,不然业务写一半再改表结构,涉及源码的改动量很大。
我见过不少课程设计源码把费用直接做成老人表里的一个字段,导致退住结算时完全没法看历史账单。正确的做法是费用全部落到账单表,每一行代表“某位老人某个月的费用”,老人表里只保留当前状态,这样才能回答“上个月这个老人的账单是多少”“全年总收入是多少”这两类绕不开的查询。押金也单独作为一张账单记录,后面退款才有依据。
3.2 核心建表SQL:状态字段与逻辑删除是关键设计
下面这张建表SQL是核心三件套,我在多个课程设计里用的都是同一套结构,稍微改改字段就能复用。关键点在建表时就给状态字段加上注释,并统一为TINYINT,避免后面对不上。
CREATE TABLE bed ( id BIGINT PRIMARY KEY AUTO_INCREMENT, bed_no VARCHAR(20) NOT NULL COMMENT '床位编号,如3-201', building VARCHAR(50) COMMENT '楼栋', floor_no VARCHAR(20) COMMENT '楼层', room_no VARCHAR(20) COMMENT '房间号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0空闲 1占用 2维修', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0未删 1已删', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='床位表'; CREATE TABLE elder ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, gender TINYINT COMMENT '0女 1男', birth_date DATE COMMENT '出生日期,用于计算年龄', id_card VARCHAR(18) NOT NULL COMMENT '身份证号', phone VARCHAR(20), emergency_contact VARCHAR(200) COMMENT '紧急联系人及电话', bed_id BIGINT COMMENT '当前床位ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0在住 1退住 2待入住', remark VARCHAR(500), deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_id_card (id_card), KEY idx_status (status, deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人档案表'; CREATE TABLE fee_bill ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elderly_id BIGINT NOT NULL COMMENT '老人ID', bill_month VARCHAR(7) NOT NULL COMMENT '账期,格式2025-01', room_fee DECIMAL(10,2) DEFAULT 0.00 COMMENT '床位费', care_fee DECIMAL(10,2) DEFAULT 0.00 COMMENT '护理费', total_amount DECIMAL(10,2) NOT NULL COMMENT '合计金额', bill_type TINYINT NOT NULL DEFAULT 0 COMMENT '0月费 1押金 2退费', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待缴 1已缴 2已退', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_elderly_month (elderly_id, bill_month) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='费用账单表';这段SQL里有两个容易被忽略的设计。第一个是金额字段全部用DECIMAL(10,2),账单累计金额和退费计算都依赖它,绝不能用FLOAT或DOUBLE。第二个是逻辑删除字段deleted,老人退住后档案保留,方便以后查询历史记录和结算对账,真正的物理删除只在管理员误操作时才需要。
床边表里故意没有建外键,elder.bed_id只做逻辑关联。物理外键在课程设计的ER图里好看,实际跑起来很麻烦:退住要释放床位,先得清掉老人表的外键引用;床位要维修,又得确认没有在住老人。用应用层代码保证数据一致性,比数据库外键灵活得多,这也是MyBatis-Plus项目里的常见做法。
3.3 查询与索引:在住名单和账单明细怎么查得快又准
索引设计围绕三个高频查询展开:当前在住老人列表,联表带出床位号;按身份证或手机号精确查老人;按月查账单。elder表里idx_status(status, deleted)覆盖“在住且未删除”的列表查询,uk_id_card唯一索引保证身份证不重复。账单表的idx_elderly_month(elderly_id, bill_month)让“某老人某月账单”走覆盖索引,避免全表扫描。
在住老人列表的SQL长这样,这在实际页面里就是核心查询:
SELECT e.id, e.name, e.gender, e.phone, b.building, b.floor_no, b.room_no, b.bed_no FROM elder e LEFT JOIN bed b ON e.bed_id = b.id WHERE e.status = 0 AND e.deleted = 0 ORDER BY e.create_time DESC;这里用LEFT JOIN而不是INNER JOIN,是为了防止某位老人因为异常情况没有绑床位时列表查询直接把他丢出来。索引设计上有一个容易踩的坑:status字段区分度低,单独建status索引几乎没用,一定要和deleted组成联合索引才能让查询计划走索引。如果你看到在住列表上千条数据就慢,先用EXPLAIN看查询计划,多半是索引没命中。
4. 核心功能源码拆解:入住登记与费用结算的完整实现
4.1 入住登记Service:事务保证床位、档案、押金一次成功
入住登记是这套源码里最值得细看的代码,因为它在一次操作里同时涉及三张表的变动:抢占床位、写老人档案、生成押金账单。任何一步失败,前面成功的操作都要回滚,所以必须放在同一个事务方法里。
@Service public class ElderService { @Resource private ElderMapper elderMapper; @Resource private BedMapper bedMapper; @Resource private FeeBillMapper feeBillMapper; /** * 老人入住登记 * @param dto 前端传入的入住信息 * @return 新老人ID */ @Transactional(rollbackFor = Exception.class) public Long checkIn(CheckInDTO dto) { // 1. 条件更新床位:只有 status=0 的空闲床位才能被置为占用 UpdateWrapper<Bed> uw = new UpdateWrapper<>(); uw.eq("id", dto.getBedId()).eq("status", 0); uw.set("status", 1); int rows = bedMapper.update(null, uw); if (rows == 0) { throw new BizException("床位已被占用或不存在,请刷新后重新选择"); } // 2. 插入老人档案,状态默认在住 Elder elder = new Elder(); elder.setName(dto.getName()); elder.setGender(dto.getGender()); elder.setIdCard(dto.getIdCard()); elder.setPhone(dto.getPhone()); elder.setEmergencyContact(dto.getEmergencyContact()); elder.setBedId(dto.getBedId()); elder.setStatus(0); elderMapper.insert(elder); // 3. 生成押金账单 FeeBill depositBill = new FeeBill(); depositBill.setElderlyId(elder.getId()); depositBill.setBillMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); depositBill.setRoomFee(dto.getDepositAmount()); depositBill.setTotalAmount(dto.getDepositAmount()); depositBill.setBillType(1); // 1表示押金 depositBill.setStatus(1); // 押金视为已缴 feeBillMapper.insert(depositBill); return elder.getId(); } }这段代码的核心是第一步的条件更新。为什么不用“先查询床位状态,再执行update”?因为两个前台同时办理入住,都查到了同一张空闲床位,然后先后执行update,后一个就会把前一个已经入住的老人覆盖掉。UPDATE bed SET status = 1 WHERE id = ? AND status = 0这样写,数据库行锁保证只有一个请求能影响行数,另一个返回0行,直接抛出“床位已被占用”的异常。这是用乐观锁思路处理并发,不引入额外字段,代码量最小。
4.2 月度费用结算:LocalDate和BigDecimal的正确用法
费用结算的常见需求是:给一位在住老人按月生成床位费和护理费账单,账期取当前月份;退住时算清入住期间所有未结费用。生成账单前先查同一账期是否已存在记录,防止重复生成,这是最容易漏的一步。
public void generateMonthlyBill(Long elderlyId) { String month = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM")); // 防重:同一个月同一老人不能生成两次账单 LambdaQueryWrapper<FeeBill> qw = new LambdaQueryWrapper<>(); qw.eq(FeeBill::getElderlyId, elderlyId) .eq(FeeBill::getBillMonth, month) .eq(FeeBill::getBillType, 0); if (feeBillMapper.selectCount(qw) > 0) { throw new BizException(month + "账单已存在"); } Elder elder = elderMapper.selectById(elderlyId); if (elder == null || elder.getStatus() != 0) { throw new BizException("老人不在住,无法生成月费账单"); } FeeBill bill = new FeeBill(); bill.setElderlyId(elderlyId); bill.setBillMonth(month); bill.setRoomFee(new BigDecimal("1500.00")); // 床位费标准 bill.setCareFee(new BigDecimal("800.00")); // 护理费标准 bill.setTotalAmount(bill.getRoomFee().add(bill.getCareFee())); bill.setBillType(0); bill.setStatus(0); feeBillMapper.insert(bill); }金额计算用BigDecimal且构造参数必须是字符串,new BigDecimal("1500.00"),不能直接new BigDecimal(1500.00),否则又会把double的二进制误差带进来。add方法返回新对象,原对象不变,所以totalAmount要重新赋值。这个写法看着啰嗦,但每月对账的时候少一个“分”的误差,就是值得的。
退住结算会复杂一点:不足一个月按天折算。我会先算整月的账单,再用当月入住天数除以当月总天数得到比例,最后四舍五入保留两位。日期边界问题很多:1月31日入住和2月1日入住,2月的账单算法完全不同。我的习惯是统一用YearMonth做整月运算,避免直接操作日期的天数差。
4.3 Controller层:参数校验与统一返回结构
Controller层是源码里最容易被抄错的部分。有人会把所有逻辑直接写在Controller里,service层变成摆设。正确做法是Controller只做三件事:接收参数、调用service、包装返回结果。参数校验用javax.validation注解,非法请求在进入service之前就被挡掉。
@RestController @RequestMapping("/api/elder") public class ElderController { @Resource private ElderService elderService; @PostMapping("/checkIn") public Result<Long> checkIn(@RequestBody @Valid CheckInDTO dto) { return Result.success(elderService.checkIn(dto)); } @GetMapping("/list") public Result<Page<ElderVO>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { return Result.success(elderService.pageList(page, size, keyword)); } }CheckInDTO里用@NotBlank标住name和idCard,用@NotNull标住bedId和depositAmount。前端传过来的参数不合法时,全局异常处理器会捕获MethodArgumentNotValidException,返回统一格式的错误信息给前端。这里的Result.success是一个静态方法,包装了code、message、data三个字段。每个接口都返回统一结构,前端只需要处理一种数据格式,代码会清爽很多。
Controller还有一个细节:分页参数必须给默认值,keyword允许为空。很多初学源码在这里不加@RequestParam的defaultValue,前端少传一个page参数整个接口就报错,这种问题在答辩现场特别容易翻车。
5. 部署避坑与常见问题排查:源码跑起来只是开始
5.1 本地启动的最小配置:环境、SQL脚本与application.yml
先把环境定死:JDK 1.8、Maven 3.6+、MySQL 5.7或8.0。把建表SQL导入数据库,然后修改application.yml里的数据源配置。大多数源码跑不起来的第一个原因就是数据库连接串里的参数不对,尤其是时区。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/nursing_home?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0启动命令非常简单,mvn spring-boot:run,或者mvn package打成jar后java -jar target/nursing-home.jar。看到“Started Application”就说明启动成功。启动失败时先看控制台最底部的Caused by,绝大多数问题都写在最后一行,而不是中间那一长串堆栈。
MyBatis-Plus的逻辑删除配置和elder表里的deleted字段是对应的。配置生效后,所有BaseMapper自带的selectList、selectById都会自动追加AND deleted = 0,updateById会自动带上AND deleted = 0。这个特性很方便,但也坑过不少人:你手写的XML SQL里如果忘了加deleted条件,逻辑删除就不生效,数据会“复活”。
5.2 五条高频踩坑记录:现象、原因、解决一条条对
现象一:页面保存老人信息成功,但数据库里日期差8小时。
原因是JDBC连接串没设serverTimezone,MySQL驱动用了默认时区,和本机时区不一致。解决方式是在url末尾加serverTimezone=Asia/Shanghai,同时把application.yml里jackson的time-zone也设成Asia/Shanghai。只改一处不够,连接层和JSON序列化层都要统一。
现象二:退住后重新录入相同身份证的老人,报Duplicate entry。
原因很明显但不好定位:elder表里uk_id_card唯一索引还在,逻辑删除只是把deleted设为1,身份证字段依旧存在。解决方式是改造唯一索引,删除原uk_id_card,改成UNIQUE KEY uk_id_card (id_card, deleted)。这样只有当身份证相同且deleted都为0时才会冲突,历史记录保留不影响新入住。
现象三:两张同时提交的入住单抢到了同一张床位,数据库出现两个老人绑同一个床位。
原因就是没有用条件更新,而是先select再update,中间被并发插入打断。解决方式用4.1节那样的UPDATE ... WHERE status = 0,以update影响行数判断是否成功,这是最可靠的方案。如果还不放心,可以在bed表加版本号字段,用MyBatis-Plus的@Version做乐观锁,但对这个场景来说条件更新已经够用。
现象四:费用报表里金额偶尔出现0.30000000000000004。
这是把金额字段定义成了DOUBLE,或者代码里用了new BigDecimal(0.1)造成的。解决方式是把数据库列改成DECIMAL(10,2),Java代码里用new BigDecimal("0.1")这种字符串构造。任何金额运算都别用double,血泪经验。
现象五:生产环境上传老人照片后重启,图片全没了。
原因是源码里默认把上传文件写到了项目运行目录下的临时文件夹,jar重启后临时目录被清理。解决方式是在application.yml里配置自定义上传路径,比如file.upload-dir=/data/nursing-home/upload,然后写一个配置类把该路径映射成静态资源URL。这一点在课程设计里不容易暴露,但你要真拿去给机构用,一定会碰上。
6. 进阶:从能跑通到能验收——测试清单、权限拦截与上线检查
源码交付之后,验证是最后一道关口。我自己的习惯是维护一份接口验证清单:入住登记、押金账单生成、月度结算、退住释放床位、老人列表分页、按身份证查重,六条主链路用Postman逐条跑通。只测Happy Path远远不够,还要测重复提交、床位被占、身份证重复、金额为负这几个异常场景。
权限控制是一个性价比很高的加分项。给系统加一个HandlerInterceptor,拦截非登录请求,比引入Spring Security省事很多,对这类小系统也够用。核心代码非常短:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute("loginUser") == null) { response.sendRedirect("/login"); return false; } return true; } }在WebMvcConfig里注册这个拦截器,并排除登录页、登录接口和静态资源路径。这个技巧不依赖任何框架,纯Servlet API实现,放在答辩里讲“系统安全性设计”是一个很实际的支撑点。再进一步可以给菜单和按钮加权限码,但第一版做到登录拦截就够了。
最后说一个贯穿全程的教训:不要为了追求“功能多”去堆代码,先保证最小闭环能跑通。我陪人做过好几个类似的管理系统,最快翻车的路子就是先做角色权限、再搞数据报表,把入住退住放在最后,结果主流程联调时问题一堆。先用入住、在住查询、退住结算打通一条线,一周内让系统真正“能用”,比列一堆功能清单重要得多。希望帮到你。
本文还有配套的精品资源,点击获取