
上个月我陪家里人去看望一位住在养老中心的长辈前台小姑娘手忙脚乱地翻纸质台账找探访登记信息旁边的护理员拿着手机在群里发交接班记录。那一刻我突然意识到这就是“养老中心管理系统”要解决的问题——不只是把Excel搬到网页上而是把一堆散落在线下流程里的管理动作统一起来。这篇文章就是基于这样一个项目用SpringBoot从0搭建养老中心管理系统从头梳理需求、技术栈、表结构、核心功能和踩坑过程。正在准备SpringBoot毕设、或者想用一个小而完整的业务系统练手的同学可以照着我这条路走一遍。1. 养老中心的业务现实不是做产品设计是给杂乱流程补一条主线很多人在拿到“养老中心管理系统”这个题目后第一反应是赶紧建SpringBoot项目、写CRUD。这是最常见的误区。管理系统的技术难度从来不在框架而在于能不能把业务规则翻译成数据结构。我建议先花两三天时间把业务问清楚再做任何代码。1.1 一个养老中心每天到底在忙什么养老中心的日常管理动作拆开看就那么几类老人入住时登记档案包括身份证、健康情况、家属联系方式、入住合同。分配床位床位要区分自理区、半护理区、全护理区状态要能标识空置、占用、维修。护理员每天按班次做护理记录比如喂药、翻身、血压测量、康复训练这些记录需要留痕。财务每月生成费用账单包含床位费、护理费、伙食费还要记录缴费和欠费情况。家属探访要登记来访时间、来访人信息特别是疫情期间这类记录格外重要。定时任务比如体检提醒、缴费到期提醒需要系统主动推送。把这六类动作摆出来系统的模块边界就出来了老人档案管理、床位管理、护理记录、缴费管理、访客管理、系统提醒。每个模块背后都有一张或几张表支撑。1.2 模块与数据流入住、护理、收费、探访四条主线我梳理业务时通常画四条主流程代码写起来就不会乱入住流登记老人信息 → 健康初评 → 分配床位 → 生成护理计划。护理流护理员登录 → 查看责任区老人 → 填写当班护理记录 → 交班时汇总异常情况。收费流系统根据床位和护理等级生成月度账单 → 财务确认 → 记录缴费流水 → 欠费触发提醒。探访流家属到前台登记 → 探访记录关联老人档案 → 离院时签退。这四条流有一个共同特点都以老人档案为中心向外扩展。所以数据库设计时elder表一定要先建好其他表都通过elder_id关联回去。先把这个逻辑想清楚后面写ServiceImpl的时候就顺了。1.3 角色权限矩阵先分清谁能看什么、能改什么角色权限是这类系统里最容易被低估的部分。常见的角色至少包括系统管理员、护理组长、护理员、前台、财务、家属端查询专用。权限矩阵长这样功能模块管理员护理组长护理员前台财务老人档案全部增删改查看/编辑查看自己负责区新增/查看查看护理记录全部查看本组增删改自己的记录增改只读只读床位管理全部操作查看/分配只读分配/登记只读缴费管理全部操作只读无登记全部操作访客登记全部查看只读无登记/查看无这个矩阵的意义不只是为了写权限校验代码更关键的是指导数据库查询设计。比如护理员登录后只看到自己负责的床位和老人那elder表里就需要一个nurse_id或者通过care_plan表关联查询时多一个过滤条件。很多毕设系统败在这一步——权限按钮做了但数据权限没做登录之后就看到了全院的老人数据。2. SpringBoot版本与项目骨架版本选错后面全是兼容性眼泪业务梳理完才进入SpringBoot的部分。这一节我要说一个很多人忽略的事实SpringBoot版本选型比写接口更影响项目成败。2.1 SpringBoot 2.7还是3.x按教程生态来选而不是按最新来选现在IDEA新建项目时Spring Initializr默认给的是SpringBoot 3.x。网上大量教程、老项目代码还停留在2.x于是你会遇到一堆诡异问题javax.servlet找不到、旧版Shiro配置失效、某个依赖没有Jakarta版本。不是说3.x不好而是如果以完成一个管理系统为目标选一个生态匹配的组合更重要。我个人的推荐是两个组合稳妥组合JDK 8 SpringBoot 2.7.18网上教程覆盖率最高遇到问题一搜就有答案。新一点组合JDK 17 SpringBoot 3.2.x顺手熟悉新特性但遇到老代码要自己做兼容迁移。从纯教学和毕设角度看我建议用2.7.18。它属于2.x的最后一个维护版本修复了很多问题学习成本最低。如果你导师要求用新版本那再上3.x。版本确定后pom.xml里parent坐标写死千万不要用SpringBoot的SNAPSHOT版本。2.2 初始化项目从Spring Initializr到阿里云镜像的细节创建项目时start.spring.io在国内访问偶尔很慢。你可以切换阿里云提供的初始化地址https://start.aliyun.com里面同样提供SpringBoot项目模板选择依赖、生成工程压缩包体验基本一致。下载完解压后用IDEA打开先别急着写代码把Maven镜像配置好。Maven的settings.xml里加阿里云镜像mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/public/url /mirror这一步能省下大量等依赖的时间。再设置IDEA的Maven Runner选项把JVM参数设为-Dfile.encodingUTF-8避免控制台中文乱码。这类“环境小细节”不会出现在教程里但实际开发时几乎每个人都踩过。2.3 目录结构约定与application.yml里的小重点项目骨架我习惯按业务模块分包而不是按三层架构硬分。目录长这样com.example.eldercare ├── common # 统一返回结果、通用工具类 ├── config # MyBatis-Plus配置、拦截器配置、跨域配置 ├── controller # 接口层 ├── entity # 数据库实体 ├── mapper # MyBatis-Plus Mapper接口 ├── service # 业务层接口与实现 ├── dto # 前端交互对象比如登录参数、查询条件封装 ├── exception # 自定义异常与全局异常处理器 └── ElderCareApplication.java实体类不要和DTO混用。很多新手直接把entity丢给前端返回其实会让接口暴露多余字段比如身份证号、家庭住址。系统里我建议entity负责数据表映射DTO负责接口出入参两条线分开。application.yml里几个容易漏的配置MySQL连接要带上时区参数否则日期时间差8小时MyBatis-Plus的日志要打开开发阶段能直接看到SQL上传文件大小限制要按需求调整默认1MB往往不够用。spring: datasource: url: jdbc:mysql://localhost:3306/eldercare?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB max-request-size: 20MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true server: port: 8080如果你用的是SpringBoot 3.x MyBatis-Plus 3.5.x数据源要引入的是mybatis-plus-spring-boot3-starter而不是老的mybatis-plus-boot-starter这个细节卡了我一个下午后面排错章节会展开讲。3. 数据库表设计与持久层选型把业务翻译成表再把表翻译成代码业务理清了SpringBoot骨架搭好了接下来是最核心的一步设计表结构。这一步做得扎实写接口就是翻译工作做得糙后面每个查询都痛苦。3.1 九张核心表字段怎么定、外键怎么关联我最终落地了九张表elder老人档案、bed床位、room房间、care_record护理记录、employee员工、user登录账号、payment缴费流水、visitor访客记录、notice系统提醒。核心表的建表要点如下CREATE TABLE elder ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL COMMENT 身份证号加密存储, gender TINYINT, birth_date DATE, health_level TINYINT COMMENT 1自理 2半自理 3全护理, bed_id BIGINT, family_phone VARCHAR(20), emergency_contact VARCHAR(50), emergency_phone VARCHAR(20), entry_date DATE, status TINYINT COMMENT 1在住 2退住 3请假外出, create_time DATETIME, update_time DATETIME );注意几点身份证号在页面展示时需要脱敏所以数据库里存完整值后端返回时统一处理这也印证了DTO和entity分离的必要性bed_id不建物理外键只建索引因为养老中心会有老人退住、换床、临时外出物理外键在更新时容易锁表health_level这个字段直接影响后续费用计算和护理计划属于高频查询条件一定要建索引。护理记录表care_record是数据量增长最快的表每天每个护理员都要写多条。设计时把多个老人批量记录的公共信息放进employee_care_batch表单独老人每一条再进明细表这样既能一次性交班又不丢数据。不过毕设系统为了简化可以只做明细表但字段必须包含nurse_id、care_type、care_content、record_time。3.2 选MyBatis-Plus而不是JPA或JDBC的原因ORM选型上我的观点很明确这类管理系统用MyBatis-Plus不要用JPA更不要用原生JDBC。三者的区别我用一个表格说清楚维度MyBatis-PlusSpring Data JPA原生JDBC单表CRUD继承BaseMapper即可继承JpaRepository即可手写大量模板代码复杂SQLXML或注解写原生SQLJPQL/自定义查询较绕灵活但代码量大学习成本低中特别是懒加载和会话管理低分页分页插件一行代码Pageable手动拼LIMIT适合场景业务系统、CRUD为主领域模型复杂、一对多场景多追求极致SQL控制JPA在权限系统、实体关系复杂的场景下很强大但在这类业务系统的简单查询面前反而是MyBatis-Plus的LambdaQueryWrapper最顺手。原生JDBC则要写太多样板代码不是不可以是没必要在毕设阶段折磨自己。3.3 组合查询与分页一个能直接抄的老人列表接口以老人列表接口为例我给它定的需求是支持按姓名模糊查询、按健康等级筛选、按在院状态筛选、分页返回床位号。用MyBatis-Plus实现很简单。先配置分页插件这是很多人最容易漏的一步。没配分页插件Page对象返回的total是0、记录数还是全量这个坑后面还会细说。配置代码Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }然后Service层里用LambdaQueryWrapper拼条件Override public PageElderVO pageElder(ElderQueryDTO query) { PageElder page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperElder wrapper Wrappers.ElderlambdaQuery() .like(StringUtils.hasText(query.getName()), Elder::getName, query.getName()) .eq(query.getHealthLevel() ! null, Elder::getHealthLevel, query.getHealthLevel()) .eq(query.getStatus() ! null, Elder::getStatus, query.getStatus()) .orderByDesc(Elder::getCreateTime); PageElder result elderMapper.selectPage(page, wrapper); // 这里把Entity转成VO填充床位号、家属脱敏信息 return convertToVO(result); }注意eq和like第一个参数是boolean类型条件条件成立才拼进SQL。这个写法比手写if判断干净得多我用下来最大的体感是接口里再也不需要一堆StringUtils.isEmpty的if分支了。4. 核心功能的落地与取舍权限、定时任务、文件上传骨架和数据层就绪后开始填业务功能。这一节讲三个绕不开的功能点每一个都有明确的取舍逻辑。4.1 登录与权限不迷信Spring Security用拦截器先跑通很多教程一上来就要求用Spring Security JWT。但Spring Security的过滤器链、UserDetailsService、认证管理器每一个概念都能让新手卡一周而且多数配置代码是在背答案。对于单体管理后台我更推荐先用拦截器加Token的方式跑通接口权限用自定义注解标记后面真有需求再平滑切换。核心思路用户登录时校验账号密码成功后生成一个随机Token保存到Redis或内存ConcurrentHashMap并把用户ID和角色放进Token关联的缓存里。每次请求带着Token拦截器从Redis取出用户信息判断角色是否匹配接口要求的权限。一个简化版的拦截器核心逻辑Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { throw new BusinessException(401, 未登录); } LoginUser user tokenService.getUserByToken(token); if (user null) { throw new BusinessException(401, 登录已过期); } // 把user放到ThreadLocal后续Service层可以直接取 UserContext.set(user); return true; } }然后给需要权限的接口加上自定义注解比如RequireRole(admin)在拦截器里读取HandlerMethod上的注解做角色校验。这套方案代码量大概二三百行但每一步都是自己写的出了问题也很好改。如果非上Spring Security不可建议等主体功能全部跑通后再替换而不是一开始就引入那套复杂配置。4.2 定时任务体检提醒和费用到期用Scheduled实现养老中心每天都有固定动作早上提醒护理员给特定老人测血压月底提醒缴费。这两个需求用SpringBoot的Scheduled就能解决。在启动类上加EnableScheduling然后在Service里写定时方法Component public class HealthRemindTask { Scheduled(cron 0 0 8 * * ?) public void remindBloodPressure() { ListElder elders elderMapper.selectList( Wrappers.ElderlambdaQuery().eq(Elder::getHealthLevel, 3)); // 插入提醒记录到notice表或者推送消息给对应护理员 } }cron表达式用的是Spring的6位格式网上很多五位的cron是Quartz格式直接抄过来会报错。这个点最容易踩。再提醒一句定时任务方法里要包try-catch哪怕执行失败也要记录日志否则任务异常会导致整个调度线程停止后面的任务全部不执行。费用到期提醒的逻辑类似查询payment表里本月未缴费的记录如果有往notice表插入提醒数据同时把待办数量放进首页统计接口。这类消息性提醒不要直接发短信或微信推送先做系统内通知闭环后续接入第三方渠道再替换实现类。4.3 文件上传老人照片存储的路径与访问策略老人档案需要上传证件照和健康报告这是一块看似简单实则有坑的功能。我采用的方案是存本地磁盘加静态映射而不是直接存数据库。数据库只存文件路径字符串好处是上传下载速度快、数据库体积可控。上传接口的处理逻辑PostMapping(/file/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(400, 文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID() ext; String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); Path dir Paths.get(uploadDir, datePath); Files.createDirectories(dir); file.transferTo(dir.resolve(filename).toFile()); return Result.success(/files/ datePath / filename); }文件名一定要用UUID重命名不然用户上传的“张三.jpg”会覆盖同名文件而且中文文件名在跨平台部署时容易乱码。目录按日期分一层方便后续清理和归档。配置类里注册静态资源映射把/files/路径映射到磁盘目录Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file: uploadDir /); } }注意addResourceLocations最后要带斜杠否则映射不生效。部署到Linux服务器后uploadDir目录要手动创建并给权限很多系统上线后图片加载不出来查半天发现是目录不存在。5. 开发中途踩过的三个坑从报错到定位再到修复这部分是我最想写的内容。这三个坑不冷门每个都在开发过程中真实发生过我把完整的排查链路写出来希望你能跳过。5.1 SpringBoot 3.x的jakarta迁移坑NoClassDefFoundError到根因定位现象我从Spring Initializr默认创建了一个3.2.x项目然后参考老教程在Controller里写import javax.servlet.http.HttpServletRequest一启动直接报NoClassDefFoundError。排查链路先看控制台异常栈发现报错来自tomcat的servlet相关类。我第一反应是缺少依赖去pom里加了servlet-api结果还是不行。后来完整阅读异常信息发现类名是jakarta.servlet.http.HttpServletRequest——SpringBoot 3.x整体迁移到了Jakarta命名空间javax前缀在Tomcat 10阶段就被移除了。根因SpringBoot 3.x基于Jakarta EE 9老代码的javax.包全部需要改为jakarta.。修复把所有javax.servlet、javax.validation等包名批量替换为jakarta.servlet、jakarta.validation。IDEA里直接用全局替换注意pom里老版本的spring-boot-starter-validation也要确认是3.x版本。反思选版本前先查一下自己看的教程是哪个SpringBoot版本。版本差异最折磨人的不是API不同而是同样的功能在不同版本里实现类都不一样。5.2 MyBatis-Plus分页插件没配置total永远为0记录全量返回现象我写完分页查询接口前端传入pageNum1, pageSize10结果返回的total是0列表却把全表数据都返回了。排查链路先用Postman调接口确认入参没问题。然后看控制台日志发现执行的SQL不是SELECT * FROM elder LIMIT 10而是无条件全表查询。这就很奇怪了因为LambdaQueryWrapper明明有分页参数。随后检查MyBatis-Plus版本是3.5.3然后翻看官方文档发现3.5.x以后PaginationInnerInterceptor必须手动注入才能生效。如果没注入selectPage不会拼接LIMIT语句。根因MyBatis-Plus的分页Interceptor是可选组件不配置等于没有分页能力但它不会报错只会在返回数据时静默失效数据库全表扫描。修复补上MybatisPlusConfig里PaginationInnerInterceptor的配置重启后再看SQL变成SELECT ... LIMIT 10了total也正常。这个坑隐蔽的地方在于不报错、不明显你只会觉得数据哪里不对。我把“分页total异常先查分页插件”这条记进了自己的排查清单。5.3 LocalDateTime序列化格式本地正常、线上显示T分隔符现象老人档案的createTime字段在本地环境接口返回的是“2025-01-15 10:00:00”部署到服务器后变成“2025-01-15T10:00:00”前端直接展示时多了一个字母T。排查链路第一反应是服务器JDK版本不同导致检查后发现两边都是JDK17排除。接着怀疑是Jackson版本差异但本地和线上用的是同一个jar包。最后怀疑是环境变量或配置文件不一致比对application.yml也没发现差异。后来想起来本地IDEA里我加过全局的日期格式配置而线上打包时那个配置类所在的包被某次提交误删了。检查git历史后发现某次重构把GlobalJacksonConfig删除但本地IDEA还留着编译文件的缓存所以本地一切正常线上暴露问题。根因LocalDateTime被Jackson默认序列化为ISO-8601格式即带T的格式。要让所有接口统一返回“yyyy-MM-dd HH:mm:ss”需要显式配置ObjectMapper。修复写一个全局Jackson配置类Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder - { builder.simpleDateFormat(yyyy-MM-dd HH:mm:ss); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializers(new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }教训是线上的行为才是真实行为本地IDE把自己的问题掩盖了。遇到“本地正常线上异常”先查两者差异不要凭感觉猜。6. 部署与验收从maven打包到Docker跑起来再检查一遍功能闭环功能全部跑通之后剩下的就是部署和验收。这一节不讲太多高级运维只讲把系统真正跑起来的完整链路。6.1 打包、Dockerfile与docker-compose一键启动首先确保代码可以在本地全量跑通然后执行maven打包命令mvn clean package -DskipTeststarget目录下会生成一个可执行的jar包。接下来写一个简单的Dockerfile用多阶段构建减小镜像体积FROM maven:3.9-eclipse-temurin-8 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:8-jre WORKDIR /app COPY --frombuild /app/target/eldercare.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]然后用docker-compose把MySQL和SpringBoot应用一起拉起来version: 3 services: mysql: image: mysql:8 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: eldercare ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql app: build: . depends_on: - mysql environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/eldercare?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai ports: - 8080:8080 volumes: mysql-data:docker-compose起来后宿主机的8080端口就是系统入口。服务器上如果用宝塔面板管理也只需要在Docker管理器里指定镜像和端口映射其他逻辑一样。要特别提醒数据库初始化不要依赖开发环境的连接信息。我习惯在Dockerfile里加一个init SQL脚本SpringBoot启动时通过spring.sql.init配置自动执行建表语句这样部署一个新环境不需要手动导sql文件。6.2 功能验收清单正常流程和异常流程都要测部署完不能立刻说“完成”我每次都会跑一遍验收清单。除了正常业务流还要专门测异常输入因为答辩时老师最喜欢问的就是“如果传了非法参数会怎样”。测试类型测试项预期结果正常流新增老人→分配床位→写护理记录→生成账单→缴费全链路数据贯通正常流护理员登录后只看得到自己负责区老人数据权限生效异常流不登录直接访问业务接口返回401不返回数据异常流老人重复身份证号录入提示已存在不产生脏数据异常流上传超过10MB的图片返回清晰错误提示异常流分页页码传0或负数返回第一页不抛异常这类问题用统一异常处理器兜底Controller里不写try-catch会干净很多。6.3 给答辩或验收的建议讲业务设计别只讲CRUD最后聊点评审视角的事。系统跑通只是及格高分的关键在于你能讲清楚“为什么这样设计”。从第一个页面到数据流我建议的讲述逻辑是先讲清楚养老中心的业务痛点和四条主线然后讲角色权限矩阵如何影响表结构设计再演示一两个组合查询来解决真实业务问题最后用异常处理的演示收尾。中间如果被问到某个表为什么没有外键不用慌诚实回答“业务上有退住、换床场景物理外键会限制灵活性所以通过索引和应用层保证一致性”——这就是真实系统里的常见取舍比背标准答案有用得多。另外准备一两个曾经踩过的坑和排查思路。评审不要求你的项目完美但要求项目是你自己写出来的。能讲清楚一个bug从出现到定位到修复的过程比堆十张截图更能证明你的实际动手能力。