Java养老管理系统实战:SSM架构、费用计算与避坑指南
简介这份源码面向 Java 开发者、养老行业信息化人员及系统设计学习者以可运行的完整工程呈现中州养老系统的设计与实践。压缩包共 141 个文件包括 120 个 Java 源文件、17 个 XML 配置文件以及 YML、JSON、Git 忽略文件和说明文本整体约 396KB结构清晰便于按模块查阅。Java 文件实现用户管理、服务预约、健康档案等核心业务逻辑并涉及反射、泛型、多线程等特性XML 与 YML 负责系统及数据库配置JSON 用于数据存储与快速读取配置文件采用直观的标签结构便于调整连接参数。工程按通用、Web、服务三个子模块拆分分层明确三个模块分别负责通用能力、前端交互与服务端逻辑同时覆盖登录控制、通用工具类、分页响应封装等基础能力适合理解企业级 Spring Boot 项目结构。已有 349 人学习浏览源码内还包含 Swagger 配置、自动填充拦截器、基础枚举与统一响应封装等细节可作为养老系统开发与 Java Web 后端学习的参考样例也适合课程设计与毕业设计借鉴。1. 养老系统用 Java 做到底卡在哪儿中州养老系统说白了就是给养老院、护理机构做的一套内部管理软件管床位、管护工排班、管老人档案、管费用结算。这类系统市面上不少但真正能用 Java 从零写出一套能上线、能交付的源码难点从来不在 CURD而在业务建模和费用计算——老人入住状态多变、护理等级调整频繁、费用项叠加退款逻辑复杂稍不留神账就算错了。这篇笔记我按一线落地的方式把一套可运行的 Java 养老系统从架构选型、数据库设计到核心代码全部拆开讲适合正在做 Java 课程设计、毕业设计或者公司真要启动养老信息化项目的开发者参考。我会把参数、边界和坑都标出来照着做能省掉至少两周的摸索时间。2. 先定技术选型为什么是 SSM 而不是微服务养老系统这类业务管理系统并发量不大、数据量可控、核心诉求是稳定和好维护所以技术选型的逻辑和互联网高并发项目完全不一样。我一般会直接锁定单体应用 SSMSpring Spring MVC MyBatis或者 Spring Boot MyBatis前端用 JSP 或 Thymeleaf 服务端渲染数据库用 MySQL缓存按需上 Redis。这套组合在养老、医疗、政务类项目中是绝对的主流招人容易、排错简单、部署不挑环境。2.1 单体架构为什么更适合养老院场景先看真实业务一家 300 张床位的养老院日常在线操作人数撑死 50 人峰值出现在月初收费和对账也就上百个请求。这种量级上微服务、上消息队列纯属自己给自己挖坑。单体应用把登录认证、老人管理、床位管理、费用管理、报表统计全部放在一个工程里开发调试方便打一个 WAR 包丢进 Tomcat 就能跑。选型时我建议用 Spring Boot 而非传统 SSM 的 XML 配置版本。Spring Boot 的自动装配能把数据源、事务、静态资源这些琐碎配置收敛掉课程设计和实际交付都更快。持久层用 MyBatis 而不是 JPA因为养老系统的报表查询、费用统计 SQL 比较复杂MyBatis 的 XML 里写 SQL 更直观也方便后期 DBA 优化。2.2 前端方案与服务端渲染的取舍很多 Java 开发者一上来就想前后端分离Vue Spring Boot看着时髦但对养老系统这类内部管理系统未必合适。养老院的一线操作人员是护士和行政他们用的是院里的老电脑、老浏览器前端工程化带来的构建成本和兼容性问题会变成交付时的包袱。我一般用 JSP Bootstrap 或者 Thymeleaf Bootstrap服务端渲染一方面 SEO 无所谓但首屏快另一方面权限控制可以直接在服务端模板里按角色渲染菜单少写一层前端路由守卫。如果非要前后端分离那一定要把接口权限、跨域、Token 刷新机制提前定好否则后期联调会非常痛苦。这块后面写核心代码时会给出具体的拦截器方案。2.3 环境版本和基础依赖清单环境这块踩坑最多的是版本不匹配。我常用的组合是这样你可以直接照抄组件版本说明JDK1.8 或 11养老系统不需要 17 的新特性8 最稳Spring Boot2.7.x2.x 生态成熟资料最多MyBatis3.5.x mybatis-spring-boot-starter 2.3.x注意 starter 版本要和 Spring Boot 匹配MySQL5.7 或 8.0生产建议 8.0字符集 utf8mb4Maven3.6依赖管理Tomcat内嵌即可生产可换外置 Tomcat提示JDK 版本千万别追新。有同事用 JDK 17 跑 Spring Boot 2.6结果 cglib 代理报错排查半天发现是字节码版本不兼容换回 JDK 8 一切正常。Java 的世界里稳定压倒一切。3. 数据库设计十张核心表与三套账的坑养老系统数据库设计的核心不是表多而是状态机清晰、费用可追溯。我常跟人说养老系统的复杂度一大半在账上老人从入住到退住中间可能经历护理等级调整、临时外出、转区、请假住院每一件事都影响计费。表结构设计不好后面写计费引擎的时候就会翻车。3.1 核心业务表及字段设计我按一个 300 床位的养老院来做最小可用设计十张表覆盖入住、护理、餐饮、费用四大条线1. 长者档案表elder字段id、name、gender、birth_date、id_card、phone、emergency_contact、health_status、level_of_care自理/半失能/失能、room_id、bed_id、status在院/外出/住院/退住、admit_date、leave_date。level_of_care这个字段很关键它直接决定基础护理费的单价而且会随着老人身体状态变化而调整每次调整都要留记录所以还需要一张care_level_log表记录变更历史和操作人。这是业务审计的需要养老行业对操作留痕有要求。2. 床位表room/bed房间和床位分两张表房间有 room_type单人间/双人间/三人间、floor、area床位有 bed_no、room_id、is_occupied。查询空床位的 SQL 要联合这两张表同时要排除status已锁定的床位——锁定场景是床铺消毒或者维修这个细节经常被忽略。3. 护工表caregiver与排班表schedule护工表字段简单姓名、手机、资质等级。排班表是难点一个护工一天有三个班次早/中/夜每个班次负责一个护理区排班不能冲突跨天夜班要特殊标记。排班表和护理区、日期、班次做联合唯一索引这是防止重复排班的最有效手段。4. 费用相关表账单bill、收费项fee_item、缴费记录payment收费项表定义费用类型床位费、基础护理费、餐饮费、医疗护理费、押金。账单表记录每个老人每月生成的账单明细。缴费记录表对应每一笔实收。这里最核心的是账单金额必须由系统自动计算不允许手工改真要改必须走减免流程并留审批记录。5. 餐饮管理表meal_order/dining养老院的餐饮不是食堂打饭那么简单要支持正常餐、流食、糖尿病餐、低盐餐、鼻饲。每个老人的饮食禁忌和餐食类型要单独建表和医嘱关联。餐饮表按天记录月底汇总作为餐饮费依据。6. 操作日志表operation_log所有敏感操作修改费用、调整护理等级、退住结算都要落日志字段包括操作人、操作时间、操作类型、旧值、新值。这条最好一开始就设计进去等上线后再补日志数据基本是残缺的。3.2 三套账的设计押金、预存金、月度账单养老系统的费用模型和普通 SaaS 很不一样它有三套独立的账很多新手只做了一套账单表后面对账的时候痛不欲生。第一套是押金。老人入住时交一笔押金通常是一个月费用总额退住时扣除违约和欠费后退还。押金是一笔独立资金不能和月度账单混在一起。我一般用deposit_record表单独记录流水。第二套是预存金。很多养老院要求老人预存一笔钱在账户里每月账单从预存金里扣不够了再催缴。预存金涉及充值和扣款两方向流水用prepaid_account加prepaid_flow表来实现。第三套是月度账单。每月 1 号系统自动为上月在院老人生成账单账单明细要拆到每一个收费项并且支持中途变更——比如 15 号护理等级从半失能调到失能那这个月的护理费要按两段分别计算。这是整个系统里最容易算错的地方差一天都有人找你扯皮。我在 3.3 里专门讲这个计算逻辑的落表方案。3.3 费用计算的状态机与关键 SQL护理费按月计算但当月护理等级调整时计算规则是按天计算变更前和变更后的费用。比如 3 月共 31 天1-15 号按半失能1500/月16-31 号按失能2500/月那当月护理费 1500/3115 2500/3116。这里除法精度必须用 BigDecimal 且保留两位小数舍入模式RoundingMode.HALF_UP。核心查询是拿到一个老人在某个月的护理等级变更序列SELECT level_of_care, effective_date, expire_date FROM care_level_log WHERE elder_id #{elderId} AND effective_date lt; #{monthEnd} AND (expire_date IS NULL OR expire_date gt; #{monthStart}) ORDER BY effective_date ASC这里的坑在于expire_date的设计。我一般约定新记录生效时把上一条记录的 expire_date 更新为新的 effective_date 减一天这样查询时用 月末 AND 月初就能覆盖所有区间清晰且不容易漏。如果你用窗口函数去算生效区间逻辑上绕一层排查时反而费劲。费用计算引擎我放在第 4 章代码里讲这里先明确落表结构每月生成账单主表 bill老人、月份、状态、总金额账单明细表 bill_item收费项、单价、天数、金额、备注。报表统计一个月总额时直接 SUM(bill_item.amount) JOIN bill WHERE status已确认 即可性能很好。4. 核心模块代码登录鉴权、排班查重与账单生成的落地实现这一章是把设计落到代码的关键我选三个最核心、最容易写错的模块来写登录鉴权含权限拦截、护工排班查重、月度账单自动生成。每个模块都给出可以抄作业的核心代码片段并解释参数含义和易错点。4.1 登录鉴权拦截器 Session vs JWT养老系统是内部系统我不推荐上 Spring Security JWT 那套复杂方案Session 拦截器完全够用。但要注意密码存储必须加盐哈希不要用 MD5MD5 撞库太容易了。用 Spring Security 的BCryptPasswordEncoder做密码哈希或者用 Shiro 的哈希工具都行。实现一个登录拦截器是最直接的做法Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 String uri request.getRequestURI(); if (uri.startsWith(/login) || uri.startsWith(/static) || uri.startsWith(/assets)) { return true; } // 从 Session 中取登录用户 Object user request.getSession().getAttribute(loginUser); if (user null) { // AJAX 请求返回 401页面跳转登录页 if (XMLHttpRequest.equals(request.getHeader(X-Requested-With))) { response.setStatus(401); } else { response.sendRedirect(/login); } return false; } return true; } }在 Spring Boot 里注册这个拦截器时有一个非常容易踩的坑拦截器放行的静态资源路径不生效原因是 Spring Boot 2.x 默认静态资源路径是/static/**但如果你设置了spring.mvc.static-path-pattern拦截器里放行的前缀要和它保持一致否则样式表全被拦掉页面裸奔。注册拦截器时还要注意排除错误页面和 faviconConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /error, /favicon.ico, /static/**); } }参数说明addPathPatterns定义被拦截的路径范围/**是全路径。excludePathPatterns放行白名单一定要把/error排除否则登录超时后 Spring Boot 的 Basic Error Controller 也会被拦返回一堆难以理解的 JSON。角色鉴权我建议在拦截器基础上增加一个简单的注解RequireRole(admin)用 HandlerInterceptor 的preHandle检查当前用户角色是否匹配。不要上 RBAC 三张表那套重模型养老院角色就四五种管理员、护士长、护士、财务、护工一张用户表加一个 role 字段足够。4.2 护工排班查重联合唯一索引与事务边界排班冲突是养老系统里很容易被忽视的问题。一个护工不能同一天既上早班又上晚班一个护理区一个班次也不能出现两个护工同时负责。这个查重逻辑如果只靠代码判断高并发下会有双写风险必须数据库约束 代码校验双保险。数据库层面的方案是在排班表上建联合唯一索引CREATE UNIQUE INDEX uk_schedule ON schedule (caregiver_id, work_date); CREATE UNIQUE INDEX uk_schedule_area ON schedule (area_id, work_date, shift_type);第一个索引保证一个护工一天只能有一条排班第二个保证一个护理区一天一个班次只有一个护工。这两个约束是兜底真正的业务校验仍然要在代码里做因为要给出友好的错误提示。代码层面的校验逻辑Service public class ScheduleService { Autowired private ScheduleMapper scheduleMapper; Transactional(rollbackFor Exception.class) public void addSchedule(ScheduleDO schedule) { // 1. 校验护工当天是否已有排班 int count scheduleMapper.countByCaregiverAndDate( schedule.getCaregiverId(), schedule.getWorkDate()); if (count gt; 0) { throw new BizException(该护工当天已有排班请勿重复安排); } // 2. 校验护理区班次是否已有人 int areaCount scheduleMapper.countByAreaAndDateAndShift( schedule.getAreaId(), schedule.getWorkDate(), schedule.getShiftType()); if (areaCount gt; 0) { throw new BizException(该护理区该班次已安排护工请调整); } // 3. 插入排班记录 scheduleMapper.insert(schedule); } }注意Transactional要加在 service 方法上而不是 mapper 上。事务的边界是整个业务操作先查重再插入这两步必须在一个事务里。如果分开两个方法各开一个事务中间就有窗口期——两个请求同时查到 count0然后都执行 insert唯一索引兜底倒是能拦住但异常信息就变成了 DuplicateKeyException用户体验很差。夜班的日期归属也是一个常见的需求模糊点。比如 3 月 1 日晚班实际时间是 3 月 1 日 20:00 到 3 月 2 日 08:00排班表上的 work_date 写 3 月 1 日但在统计 3 月 2 日护理人力时这个夜班应该算在 3 月 2 日头上。这个逻辑必须和护理部确认清楚否则后面做人力成本统计时数据对不上运营肯定找你麻烦。4.3 月度账单自动生成计费引擎与 BigDecimal 精度这是整个系统最核心的部分也是最容易翻车的部分。月度账单生成的入口是一个定时任务每月 1 号凌晨跑遍历上月在院且有费用的老人按费用项逐项计算生成主账单和账单明细。计费引擎的骨架如下Service public class BillingEngine { Autowired private ElderMapper elderMapper; Autowired private BillMapper billMapper; Autowired private CareLevelLogMapper careLevelLogMapper; Transactional(rollbackFor Exception.class) public BillDO generateMonthlyBill(Long elderId, YearMonth month) { // 1. 幂等检查同一老人同一月份不允许重复生成 BillDO existing billMapper.findByElderIdAndMonth(elderId, month); if (existing ! null) { throw new BizException(该老人本月账单已生成请勿重复操作); } // 2. 计算护理费按护理等级变更区间分段计算 BigDecimal careFee calcCareFee(elderId, month); // 3. 计算床位费包月制本月入住不足整月按天折算 BigDecimal bedFee calcBedFee(elderId, month); // 4. 计算餐饮费按实际餐食订单汇总 BigDecimal mealFee calcMealFee(elderId, month); // 5. 汇总生成账单 BillDO bill new BillDO(); // 省略 setter billMapper.insert(bill); return bill; } private BigDecimal calcCareFee(Long elderId, YearMonth month) { LocalDate monthStart month.atDay(1); LocalDate monthEnd month.atEndOfMonth(); // 查询护理等级变更区间 ListCareLevelLogDO logs careLevelLogMapper.findEffectiveBetween( elderId, monthStart, monthEnd); BigDecimal total BigDecimal.ZERO; LocalDate cursor monthStart; for (CareLevelLogDO log : logs) { LocalDate effectiveDate log.getEffectiveDate(); if (effectiveDate.isAfter(cursor)) { // 计算上一段区间的费用 total total.add(calcSegmentFee( cursor, effectiveDate.minusDays(1), log.getLevelOfCare(), month)); cursor effectiveDate; } } // 最后一段到月末 // 注意如果当月 1 号之前已经是某个等级logs 里的第一条记录的 effective_date 可能早于 1 号 // 此时应从 monthStart 算起 return total; } }这个方法的逻辑核心是把当月时间轴按护理等级的有效期切成若干段每段用不同的单价计算。最容易出错的是第一条care_level_log的有效期早于当月 1 号的情况——此时该等级的计费起点应该是当月 1 号而不是 effective_date。代码里用 cursor 变量维护当前计费起点每次遇到新等级就结算上一段最后把 cursor 到月末的尾巴结算掉。这个过程想清楚之后代码就不会乱。预算金额用 BigDecimal 而不用 double 的原因不需要多解释钱相关的计算用浮点数就是对自己不负责。这里给你一个统一的精度模板BigDecimal dailyFee monthlyFee .divide(BigDecimal.valueOf(daysOfMonth), 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(actualDays)); dailyFee dailyFee.setScale(2, RoundingMode.HALF_UP);参数说明divide的第一个参数是除数第二个是保留小数位数第三个是舍入模式。中间过程我保留 4 位小数最后结果再四舍五入到 2 位这样能最大限度减少累计误差。如果你每一步都只保留 2 位一个月算下来常常差几毛钱对账时财务会来找你。定时任务用 Spring 的Scheduled(cron 0 0 1 1 * ?)配置每月 1 号凌晨 1 点执行。但要像上面代码那样做幂等检查防止手动触发或重复调度导致重复出账。生产环境建议加一个bill_generate_log表记录每次任务执行的批次号和状态方便回溯。5. 避坑指南养老系统开发中最常踩的五个坑养老系统开发过程中我积累了不少血泪经验挑五个最典型的写在这里每条都按现象、原因、解决的思路讲清楚。坑一床位状态与实际入住对不上现象床位列表显示空床但办理入住时系统提示床位已被占用或者老人已经退住床位还锁定着。原因床位状态和老人入住状态没有做联动更新退住时只改了老人表的状态忘了释放床位。这是典型的业务一致性 bug在开发时不容易发现到月底做入住率统计时数据就对不上了。解决入住退住操作放到一个事务里同时更新 elder 表和 room/bed 表。更进一步在数据库层面用触发器兜底或者写定时任务做 nightly 对账把“床位已占用但无在院老人”和“在院老人但床位空闲”的异常数据扫出来。我一般会在系统里放一个“数据体检”页面一键跑这些校验 SQL。坑二Excel 导出中文文件名乱码现象用 POI 导出的报表 Excel文件名是中文时在 Chrome 里下载乱码。原因HTTP 响应头Content-Disposition里的 filename 默认只支持 ASCII中文需要做 RFC 5987 编码。解决文件名做 URL 编码处理String fileName URLEncoder.encode(3月费用报表.xlsx, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName);参数说明URLEncoder.encode会把空格编码成但 URL 标准里空格应该是%20所以要 replace 一下。filename*带星号是 RFC 5987 的标准写法Chrome、Firefox、Edge 都支持IE 就算了养老院里的老电脑如果还在用 IE那这个方案救不了你。坑三MyBatis 时间范围查询查不到当月数据现象查询某个月的费用账单结果少了当月的部分数据月底的账单没出来。原因日期条件用了lt; endDate但 endDate 是2025-03-31而数据库里存的时间是2025-03-31 10:23:45直接把 31 号当天所有数据过滤掉了。解决日期范围的右边界统一用 endDate 1 day或者lt; endDate interval 1 day不要拼接字符串。MyBatis 里写法是lt;if testendDate ! nullgt; AND bill_date lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY) lt;/ifgt;这个坑在测试时往往测不出来因为测试数据大多是月初的等真正跑一个月的数据就露馅了。所有涉及日期范围查询的地方统一用这个模式不要心存侥幸。坑四Tomcat 部署后中文乱码现象本地 IDEA 里跑得好好的部署到 Linux 服务器 Tomcat 后页面和数据库交互的中文全变成问号。原因三个环节的字符集不一致Tomcat 的 URI 编码、JSP 页面编码、数据库连接串编码。解决Tomcat 的server.xml里加URIEncodingUTF-8数据库连接串加characterEncodingutf8JSP 页面顶部lt;% page contentTypetext/html;charsetUTF-8 %gt;。这三处缺一不可少一处都有可能出现那种看不见摸不着的乱码。另外注意 MySQL 的 character_set_server 要确认是 utf8mb4只改连接串不改库表字符集照样乱。坑五并发生成的账单金额不一致现象财务在 1 号当天反复手动触发多次账单生成结果同一老人出现多张账单金额还不一样。原因没有做幂等控制也没有把“检查是否已生成 插入账单”放到同一个事务和锁的范围内。解决除了代码里的事务控制还要在 bill 表上建联合唯一索引(elder_id, bill_month)。这样哪怕代码逻辑有漏洞数据库也能挡住。生产环境定时任务加一个 Redis 分布式锁或者数据库锁表保证同一时间只有一个生成任务在跑。6. 进阶压测看边界、日志排查习惯与三个必调参数系统开发完上线前我一般会做一轮简单的压力测试看看系统的真实边界在哪里不要等到养老院月底集中操作时才发现页面转圈。压测工具用 JMeter 就行模拟 50 个并发用户循环操作登录、打开档案、生成账单。盯两个指标接口平均响应时间不超过 500ms错误率 0。如果响应时间超过 1 秒优先看 SQL 有没有全表扫描——用 MySQL 的EXPLAIN查执行计划重点看type字段是不是ALL。三个必调的参数第一个是 JVM 启动参数。Spring Boot 应用默认堆内存很小生产环境至少给到-Xms512m -Xmx1g有条件给 2G。用java -jar -Xms512m -Xmx1g system.jar启动不要裸跑默认值。第二个是数据库连接池参数。Spring Boot 默认的 HikariCP 已经很好但maximum-pool-size默认 10 对于养老院规模偏小月初批量生成账单时容易排队建议调到 20。代价是数据库连接数占用多一点对一台 MySQL 来说毫无压力。第三个是 MyBatis 的map-underscore-to-camel-casetrue。不开启的话数据库字段create_time映射到 Java 属性createTime会失败结果全是 null你得写一堆 resultMap。开启后省掉大量映射样板。日志排查方面的习惯我会在项目的每个 service 方法入口打一条 info 日志记录入参关键值和耗时在 catch 块里用 log.error 打印完整堆栈。打印日志别用e.getMessage()很多异常的关键信息在 cause 里直接打 e 对象比打 getMessage() 强得多。这个习惯帮我在生产环境定位过很多次问题——养老院的人说不清楚操作步骤但日志里什么都有。最后分享一个我的习惯每一张关键业务表都预留create_time、update_time、deleted三个字段删数据一律逻辑删除。养老行业的数据要留痕这条规则能救你很多次——有人在系统里误删了老人的费用记录如果物理删除了你连后悔药都没得吃逻辑删除随时能恢复。希望这篇笔记帮到你按这个思路把中州养老系统落到能跑、能交付的源码你离真正的 Java 实战又近了一步。本文还有配套的精品资源点击获取