1. 需求拆解:这类系统到底在解决什么问题
先说一个实际场景。很多做过养老机构、社区健康驿站项目的朋友应该都有同感:老人健康信息管理这类系统,本质上不是“写代码难”,而是“把业务边界梳理清楚难”。一个老人从入住到日常照护,涉及的信息包括基础档案、体检记录、慢病随访、用药情况、家属联系方式、护工排班等等,如果这些数据散落在Excel、纸质台账和微信聊天记录里,一旦要调取某个老人的完整健康轨迹,往往要翻半天,而且数据很容易丢失或更新不及时。
springboot老人健康信息管理系统要解决的,就是把这些分散信息集中到一个平台上,让医护人员能快速查到老人的健康档案、最近体检结果、慢病管理进度;让家属能通过系统或接口看到老人的健康动态;让管理员能统计整个机构的健康数据趋势。换句话说,这套系统的核心价值是**“让健康数据流转起来”**,而不是简单做一个增删改查的CRUD后台。
从角色来看,这类系统通常有三类用户:
- 管理员/医护人员:负责老人档案的建立和维护、体检数据录入、慢病随访登记、异常指标的审核处理。
- 老人本人:实际上老人直接操作系统的比例很低,更多是查询个人健康报告、查看用药提醒,所以老人端的功能要尽量简单,界面字号要大、操作步骤要少。
- 家属:通过账号查看家里老人的健康档案、体检趋势、预警通知,这个角色的功能侧重于“查询”和“接收通知”,不需要太多的写操作。
我记得有一次和社区健康站的工作人员聊需求,对方说了一句很实在的话:“我们最需要的不是花哨的图表,而是能少填几遍表,老人来了能快速找到他上次的血压记录。”这句话对系统设计的启发很大:业务痛点决定了功能优先级,而不是技术炫技。
基于这个理解,系统的功能模块大致可以划分为下面几块:
| 模块 | 核心功能 | 面向角色 |
|---|---|---|
| 老人档案管理 | 新增/修改/查询老人基本信息、家属联系方式、入住状态 | 管理员、医护人员 |
| 体检记录管理 | 录入体检数据、历史记录查询、指标对比 | 医护人员 |
| 慢病随访管理 | 慢病类型登记、随访计划、随访结果记录 | 医护人员、管理员 |
| 异常预警提醒 | 指标超限自动识别、推送预警信息 | 管理员、家属 |
| 系统管理 | 用户账号、角色权限、操作日志 | 管理员 |
这套模块划分的底层逻辑是“人-健康事件-健康管理动作”三层结构:先有老人的基础档案,再在档案之上累积体检、随访等健康事件,最后针对事件产生管理和干预动作(预警、通知、随访计划)。后续所有接口设计和数据表设计,都围绕这条主线展开。
2. 技术选型与工程搭建:springboot版本怎么选,ORM用哪个
2.1 springboot版本选择:2.7.x还是3.x
做这类管理系统,技术选型的第一件事就是springboot版本。如果你去搜相关毕设或者企业项目的代码,会发现大量教程和开源项目还是基于springboot 2.x,这是有原因的。一方面,springboot 2.7.x对JDK 8的支持非常稳定,而很多云服务器、教学环境、旧项目的JDK还停留在1.8,升级JDK 17甚至21的成本不一定值得;另一方面,springboot 3.x底层是Spring Framework 6,javax命名空间改成了jakarta,很多老依赖(比如某些版本的druid、pagehelper)需要换适配版本,中间遇到问题的概率会高不少。
我的建议很直接:如果只是做中小规模的老人健康管理系统,没有特别需要虚拟线程、GraalVM这些新特性的场景,优先选springboot 2.7.18 + JDK 8这套组合,踩坑成本最低,网上资料也最多。当然,如果你是新项目且团队成员对JDK 17已经比较熟悉,springboot 3.2.x也完全可以,但要注意数据库驱动、连接池、分页插件这些依赖的版本兼容性。
用一个表格来对比更清楚:
| 对比项 | springboot 2.7.x | springboot 3.x |
|---|---|---|
| JDK要求 | JDK 8及以上 | 最低JDK 17 |
| 命名空间 | javax.* | jakarta.* |
| 社区资料 | 非常丰富 | 逐步完善 |
| 老项目整合 | 兼容性好 | 需要改依赖 |
| 虚拟线程 | 不支持 | 支持 |
| 推荐场景 | 大多数管理系统、毕设、传统企业项目 | 新项目、追求新特性的团队 |
2.2 ORM选型:MyBatis还是MyBatis-Plus
关于数据访问层,老人健康信息管理系统的表结构不算特别复杂,但涉及的动态查询条件比较多(按姓名、按年龄段、按慢病类型、按时间范围筛选),所以ORM选型直接影响开发效率。
我个人的经验是:优先MyBatis-Plus。原因很简单,这类管理系统的大部分操作还是单表CRUD(老人档案表、体检记录表、随访表),MyBatis-Plus的BaseMapper直接提供insert、updateById、selectPage等方法,写起来非常快,不需要为每个表写一堆XML。而遇到多表关联查询(比如查询老人最近一次体检记录同时带出档案信息),用自定义SQL加@Select注解或者XML就能搞定,也能保留MyBatis的灵活性。
有些人对MyBatis-Plus有顾虑,觉得“侵入性太强”“不如原生MyBatis可控”,实际上对于这个体量的项目完全不用担心。它只是增强,不是替换,你完全可以继续写自己的SQL。还有一个现实理由:你在网上找到的大部分springboot管理系统的开源代码,用的都是MyBatis-Plus,有问题好查、好问。
2.3 前端方案:前后端分离还是模板渲染
这一块值得多说两句。老人健康信息管理系统既可以用vue + springboot做前后端分离,也可以用Thymeleaf直接渲染页面。两种方案各有适用场景:
- 前后端分离:适合有独立前端人员、系统后续要扩展App或小程序接口的情况。vue打包后的静态文件可以放在springboot的resources/static下,也可以部署到nginx,后端只提供RESTful接口。
- Thymeleaf服务端渲染:适合一个人全栈开发、系统功能以后台管理为主的情况,不需要处理跨域、token等前后端联调问题,开发链路短。
如果你是自己开发,时间又比较紧,我的建议是先想清楚有没有“对外提供接口给家属端App”的需求。如果有,直接走前后端分离,后端接口写好,后面做小程序或App都方便;如果纯粹是机构内部使用,后台管理界面为主,Thymeleaf完全够用,没必要为了“前后端分离”而分离。
下面给出一份基于springboot 2.7.18 + MyBatis-Plus + Thymeleaf方案的pom核心依赖,方便直接抄:
<dependencies> <!-- springboot web启动器 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis-Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- Lombok减少实体类代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- Hutool工具类 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.22</version> </dependency> </dependencies>说明一下为什么加Hutool:这类系统经常要生成编号、格式化日期、做简单的excel导入导出,Hutool能省不少事。比如老人档案批量导入,用Hutool的ExcelReader读Excel就比POI原生API方便太多。
2.4 application.yml配置要点
工程搭建过程中,配置文件是关键,这里直接给一份相对完整的配置,并解释几个容易踩坑的点:
server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/elder_health?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: your_password hikari: maximum-pool-size: 15 minimum-idle: 5 connection-timeout: 30000 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.elderhealth.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id spring.thymeleaf: cache: false几个配置细节背后的原因:
serverTimezone=Asia/Shanghai:MySQL 8驱动要求显式设置时区,不设置很可能报“The server time zone value is unrecognized”的错。map-underscore-to-camel-case: true:数据库字段用snake_case(如health_status),实体类用驼峰(healthStatus),开启后自动映射,少写很多resultMap。id-type: assign_id:MyBatis-Plus默认的雪花id,Long类型主键,适合这类业务,不用关心主键生成。- Thymeleaf的
cache: false:开发阶段必须关缓存,否则改个HTML要重启服务才能看到效果,非常影响效率。
还有一点提醒,如果你的项目中同时引入了spring-boot-starter-security,那么所有接口默认会被拦截,记得配置放行路径,或者把登录认证单独设计好,避免一路403。
3. 数据库设计:老人健康业务的核心表怎么建
数据库表设计决定这个系统能走多远,也是很多新手最容易翻车的地方。以我的经验,老人健康信息管理系统的核心表不需要太多,把下面这几张表设计好,业务就能完整跑起来:老人档案表、用户表、体检记录表、慢病随访表、预警规则表。
3.1 老人档案表:业务的主索引
老人档案是系统的核心主数据,几乎所有其他表都通过elder_id关联它。字段设计上要兼顾“信息完整”和“易扩展”:
CREATE TABLE `elder_info` ( `id` bigint(20) NOT NULL COMMENT '主键ID', `elder_no` varchar(32) NOT NULL COMMENT '档案编号', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` tinyint(1) DEFAULT NULL COMMENT '性别 1男 2女 0未知', `birth_date` date DEFAULT NULL COMMENT '出生日期', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话', `emergency_contact` varchar(50) DEFAULT NULL COMMENT '紧急联系人', `emergency_phone` varchar(20) DEFAULT NULL COMMENT '紧急联系人电话', `address` varchar(255) DEFAULT NULL COMMENT '住址', `blood_type` varchar(10) DEFAULT NULL COMMENT '血型', `allergy_history` varchar(255) DEFAULT NULL COMMENT '过敏史', `chronic_diseases` varchar(255) DEFAULT NULL COMMENT '慢病情况(逗号分隔多个)', `nursing_level` varchar(20) DEFAULT NULL COMMENT '护理等级 自理/半自理/全护理', `status` tinyint(1) DEFAULT 1 COMMENT '状态 1在住 0已离开', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_elder_no` (`elder_no`), KEY `idx_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人基本信息表';需要注意两个细节。第一,chronic_diseases字段虽然用逗号分隔存了多个慢病,但这是为了方便列表展示和快速录入,真正的慢病管理还是要靠独立的随访表来做,不能把字段存储当成业务模型。第二,id_card虽然是唯一标识,但不建议直接作为主键,因为涉及隐私展示时需要脱敏处理,用自增或雪花id做主键更灵活。
3.2 体检记录表:一个设计难点
体检记录表的设计比很多人想的复杂。老人可能在不同时间做不同类型的体检,今天量血压,明天查血糖,后天做年度体检。如果把所有指标都做成固定字段(收缩压、舒张压、血糖、血脂…),遇到新增检测项目就得改表结构,非常痛苦。
这就要用到“头表+明细表”的设计模式。头表记录“某次体检的基本信息”(体检时间、体检类型、体检机构、有无异常),明细表记录“具体指标项和值”:
CREATE TABLE `physical_exam` ( `id` bigint(20) NOT NULL COMMENT '主键', `elder_id` bigint(20) NOT NULL COMMENT '老人ID', `exam_date` date NOT NULL COMMENT '体检日期', `exam_type` varchar(30) DEFAULT NULL COMMENT '体检类型 年度/季度/入托/日常', `exam_org` varchar(100) DEFAULT NULL COMMENT '体检机构', `result_summary` varchar(500) DEFAULT NULL COMMENT '总体结论', `doctor_advice` varchar(500) DEFAULT NULL COMMENT '医生建议', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_elder_date` (`elder_id`, `exam_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体检记录头表'; CREATE TABLE `exam_item_detail` ( `id` bigint(20) NOT NULL, `exam_id` bigint(20) NOT NULL COMMENT '体检记录ID', `item_code` varchar(30) NOT NULL COMMENT '指标编码 如 systolic_pressure', `item_name` varchar(50) NOT NULL COMMENT '指标名称 如收缩压', `item_value` varchar(50) NOT NULL COMMENT '指标值', `item_unit` varchar(20) DEFAULT NULL COMMENT '单位 mmHg/mmol/L', `ref_range` varchar(100) DEFAULT NULL COMMENT '参考范围', `is_abnormal` tinyint(1) DEFAULT 0 COMMENT '是否异常 1是 0否', PRIMARY KEY (`id`), KEY `idx_exam` (`exam_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体检指标明细表';这种设计的好处是新增体检指标时不用改表结构,只需在字典里新增一个指标编码。坏处是查询纵向对比时SQL稍微复杂一些,比如要看某个老人近半年的血压变化趋势,需要做一次透视查询,把明细表中的行转成列。MyBatis里可以用条件查询取出数据后在Service层重组,也可以直接在SQL里用GROUP BY + CASE WHEN做行转列。对于管理系统这种数据量级,Service层重组完全够用。
3.3 慢病随访表:状态是一个动态过程
慢病管理不是“登记一次就结束了”,而是一个持续过程。很多系统把慢病信息只存一个字段,导致随访记录无从谈起。正确的做法是单独建随访表,每次与老人的健康沟通、血压测量、用药调整都记录一条随访记录,并带上随访时的状态:
CREATE TABLE `chronic_follow_up` ( `id` bigint(20) NOT NULL, `elder_id` bigint(20) NOT NULL COMMENT '老人ID', `disease_type` varchar(50) NOT NULL COMMENT '慢病类型 高血压/糖尿病/冠心病等', `follow_up_date` date NOT NULL COMMENT '随访日期', `systolic_pressure` int(11) DEFAULT NULL COMMENT '收缩压', `diastolic_pressure` int(11) DEFAULT NULL COMMENT '舒张压', `fasting_blood_glucose` decimal(5,2) DEFAULT NULL COMMENT '空腹血糖', `medication_status` varchar(200) DEFAULT NULL COMMENT '用药情况', `life_style` varchar(500) DEFAULT NULL COMMENT '生活方式指导', `next_follow_date` date DEFAULT NULL COMMENT '下次随访日期', `doctor_name` varchar(50) DEFAULT NULL COMMENT '随访医生', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`), KEY `idx_elder_disease` (`elder_id`, `disease_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='慢病随访记录表';强调一点:随访表的核心是“按时间线记录状态变化”。设计时一定要留下next_follow_date这个字段,后续做“待随访提醒”的前置查询时,直接查这个字段就能筛出即将到期或已逾期的老人,不用额外写复杂逻辑。
3.4 预警规则表:把阈值做成可配置
老人健康系统的重头戏之一是异常预警。血压高了要通知医护人员,血糖异常要提醒家属。但如果把阈值硬编码在Java代码里,后面调整阈值就要改代码重新发版,很麻烦。比较好的方式是把预警规则做进数据库:
CREATE TABLE `alert_rule` ( `id` bigint(20) NOT NULL, `rule_name` varchar(50) NOT NULL COMMENT '规则名称', `item_code` varchar(30) NOT NULL COMMENT '关联指标编码', `operator` varchar(10) NOT NULL COMMENT '比较符 > < >= <= BETWEEN', `threshold_value` varchar(50) NOT NULL COMMENT '阈值 如 140 或 90,140', `severity` tinyint(1) DEFAULT 1 COMMENT '级别 1提示 2警告 3严重', `enabled` tinyint(1) DEFAULT 1 COMMENT '是否启用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警规则表';实际实现时,体检数据录入完成后可以遍历启用的规则,根据指标编码命中规则的话就生成一条预警记录,写入alert_record表,同时可以配合短信或公众号通知。这套方案的好处是运营人员可以在后台直接调整阈值,不用动代码。
4. 核心功能实现:从控制器到服务层的完整链路
4.1 健康档案模块实现要点
老人档案的插入和更新在技术上不复杂,但要注意业务校验。比如身份证号格式校验、手机号格式校验、档案编号唯一性校验。我用Hutool的Validator工具类来简化这些校验:
@Override @Transactional(rollbackFor = Exception.class) public boolean addElder(ElderInfoVO vo) { // 1. 校验身份证号 if (!Validator.isCitizenId(vo.getIdCard())) { throw new ServiceException("身份证号格式不正确"); } // 2. 校验手机号 if (StrUtil.isNotEmpty(vo.getPhone()) && !Validator.isMobile(vo.getPhone())) { throw new ServiceException("手机号格式不正确"); } // 3. 生成档案编号:ELD + 年月日 + 随机4位 String elderNo = "ELD" + DateUtil.format(new Date(), "yyyyMMdd") + RandomUtil.randomNumbers(4); vo.setElderNo(elderNo); ElderInfo entity = new ElderInfo(); BeanUtil.copyProperties(vo, entity); return elderInfoMapper.insert(entity) > 0; }这里有几个值得注意的细节。第一,事务注解要加,因为可能有后续的初始化操作(比如同时生成一条初始健康评估记录),保证原子性。第二,档案编号生成逻辑虽然简单,但并发情况下可能重复,可以加唯一索引兜底,如果插入时抛出DuplicateKeyException,捕获后重新生成编号即可。第三,用BeanUtil.copyProperties做VO和实体转换比手动set高效得多,但要注意VO里不要包含数据库不存在的字段,否则复制到实体会有冗余。
查询列表接口通常要支持分页和条件筛选,MyBatis-Plus的LambdaQueryWrapper非常合适:
public Page<ElderInfoVO> pageElders(ElderQueryDTO query) { Page<ElderInfo> page = new Page<>(query.getCurrent(), query.getSize()); LambdaQueryWrapper<ElderInfo> wrapper = Wrappers.lambdaQuery(); // 姓名模糊查询 if (StrUtil.isNotBlank(query.getName())) { wrapper.like(ElderInfo::getName, query.getName()); } // 慢病筛选:慢性病字段中包含对应关键字 if (StrUtil.isNotBlank(query.getChronicDisease())) { wrapper.like(ElderInfo::getChronicDiseases, query.getChronicDisease()); } // 状态筛选 if (query.getStatus() != null) { wrapper.eq(ElderInfo::getStatus, query.getStatus()); } // 按创建时间倒序 wrapper.orderByDesc(ElderInfo::getCreateTime); Page<ElderInfo> result = elderInfoMapper.selectPage(page, wrapper); // 实体转VO,补充年龄等冗余字段 Page<ElderInfoVO> voPage = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); List<ElderInfoVO> voList = result.getRecords().stream() .map(this::convertToVO) .collect(Collectors.toList()); voPage.setRecords(voList); return voPage; }4.2 体检数据录入:事务、校验与趋势查询
体检数据录入是系统中写操作最频繁的功能。头表和明细表需要在一个事务里完成写入,防止头表插入成功但明细表失败导致脏数据。Controller层接收的DTO结构大致是:体检基本信息 + 指标列表。Service层处理逻辑如下:
@Transactional(rollbackFor = Exception.class) public boolean addExamRecord(ExamRecordDTO dto) { // 1. 校验老人存在且状态有效 ElderInfo elder = elderInfoMapper.selectById(dto.getElderId()); if (elder == null || elder.getStatus() != 1) { throw new ServiceException("老人信息不存在或已离院"); } // 2. 插入头表 PhysicalExam exam = new PhysicalExam(); exam.setElderId(dto.getElderId()); exam.setExamDate(dto.getExamDate()); exam.setExamType(dto.getExamType()); exam.setExamOrg(dto.getExamOrg()); physicalExamMapper.insert(exam); // 3. 批量插入明细 List<ExamItemDetail> items = dto.getItems().stream().map(itemDTO -> { ExamItemDetail item = new ExamItemDetail(); item.setExamId(exam.getId()); item.setItemCode(itemDTO.getItemCode()); item.setItemName(itemDTO.getItemName()); item.setItemValue(itemDTO.getItemValue()); item.setItemUnit(itemDTO.getItemUnit()); item.setRefRange(itemDTO.getRefRange()); // 调用规则判断是否异常 item.setIsAbnormal(checkAbnormal(itemDTO) ? 1 : 0); return item; }).collect(Collectors.toList()); examItemDetailMapper.insertBatch(items); // 4. 触发预警检查 alertService.checkExamAlert(dto.getElderId(), dto.getItems()); return true; }这里要特别说明checkExamAlert的设计思路。异常识别不应该散落在页面代码里,而应该集中在服务层。每次体检录入后,建议把本次所有指标交给预警服务统一处理,预警服务根据实际指标值和阈值配置判断是否生成预警记录。如果有3级(严重)异常,还可以追加一个短信通知的动作。
趋势查询这部分,最常用的场景是“查看某个老人近6个月血压变化”。由于使用头表+明细表结构,需要先查出该老人近半年的体检头表ID列表,再根据指标编码查到明细。为了减少对数据库的频繁访问,可以一次性查出所有明细,然后在内存中按exam_id分组:
public List<TrendPointVO> getBloodPressureTrend(Long elderId, int months) { // 1. 计算起始日期 Date startDate = DateUtil.offsetMonth(new Date(), -months); // 2. 查询近N月的体检记录ID QueryWrapper<PhysicalExam> examWrapper = new QueryWrapper<>(); examWrapper.select("id", "exam_date") .eq("elder_id", elderId) .ge("exam_date", startDate) .orderByAsc("exam_date"); List<PhysicalExam> exams = physicalExamMapper.selectList(examWrapper); List<Long> examIds = exams.stream().map(PhysicalExam::getId).collect(Collectors.toList()); if (examIds.isEmpty()) { return Collections.emptyList(); } // 3. 查询这两个指标的所有明细 QueryWrapper<ExamItemDetail> detailWrapper = new QueryWrapper<>(); detailWrapper.in("exam_id", examIds) .in("item_code", "systolic_pressure", "diastolic_pressure"); List<ExamItemDetail> details = examItemDetailMapper.selectList(detailWrapper); // 4. 组装趋势数据 Map<Long, List<ExamItemDetail>> map = details.stream() .collect(Collectors.groupingBy(ExamItemDetail::getExamId)); // 此处省略具体的值提取和返回组装... }4.3 预警与通知:规则引擎的轻量实现
很多人一听到“规则引擎”就想到Drools,其实在这个场景下完全没必要。用一个简单的“规则表+遍历匹配”就够了。核心实现如下:
public void checkExamAlert(Long elderId, List<ExamItemDTO> items) { // 加载所有启用的规则 List<AlertRule> rules = alertRuleMapper.selectList( new LambdaQueryWrapper<AlertRule>().eq(AlertRule::getEnabled, 1)); if (rules.isEmpty()) { return; } for (ExamItemDTO item : items) { for (AlertRule rule : rules) { if (rule.getItemCode().equals(item.getItemCode()) && matchRule(rule, item.getItemValue())) { // 生成预警记录 AlertRecord record = new AlertRecord(); record.setElderId(elderId); record.setRuleId(rule.getId()); record.setItemName(item.getItemName()); record.setItemValue(item.getItemValue()); record.setSeverity(rule.getSeverity()); record.setStatus(0); // 0待处理 alertRecordMapper.insert(record); } } } } private boolean matchRule(AlertRule rule, String valueStr) { BigDecimal value = new BigDecimal(valueStr); String operator = rule.getOperator(); String threshold = rule.getThresholdValue(); if ("BETWEEN".equals(operator)) { // 阈值格式 90,140 String[] parts = threshold.split(","); BigDecimal low = new BigDecimal(parts[0]); BigDecimal high = new BigDecimal(parts[1]); return value.compareTo(low) >= 0 && value.compareTo(high) <= 0; } else { BigDecimal thresholdVal = new BigDecimal(threshold); switch (operator) { case ">": return value.compareTo(thresholdVal) > 0; case ">=": return value.compareTo(thresholdVal) >= 0; case "<": return value.compareTo(thresholdVal) < 0; case "<=": return value.compareTo(thresholdVal) <= 0; case "==": return value.compareTo(thresholdVal) == 0; default: return false; } } }这里提醒一个比较隐蔽的问题:判断“异常”和“预警”不一定是同一件事。体检明细表里的is_abnormal字段标识的是“体检指标超出参考范围”,而预警表里的“预警”则是“命中专门的预警规则并通知”。两者可能重合,但逻辑上是独立的。参考范围来自医疗机构的标准,预警规则则由机构自定义,这样设计更灵活。
4.4 权限控制与家属查询
权限这块,如果不想引入Spring Security的重量级配置,可以考虑用拦截器+注解的方式实现简单的角色控制。定义角色枚举(ADMIN、DOCTOR、NURSE、FAMILY),在Controller方法上加自定义注解@RequireRole(ADMIN),拦截器里校验当前登录用户的角色是否匹配。
不过实话实说,如果系统规模不大,我更推荐直接用Spring Security的基础能力,只使用它的登录认证和角色放行配置,不用oauth2那些复杂内容。核心配置大致是:
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/login", "/css/**", "/js/**", "/images/**").permitAll() .antMatchers("/admin/**").hasRole("ADMIN") .antMatchers("/doctor/**").hasAnyRole("DOCTOR", "ADMIN") .antMatchers("/family/**").hasAnyRole("FAMILY", "ADMIN") .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .defaultSuccessUrl("/index") .permitAll() .and() .logout() .permitAll(); }这里有一个关键点:家属的查询范围必须受到严格限制。家属登录后只能查询自己绑定老人的数据,不能遍历所有老人。实现方案是在表的关联设计中加入family_bind表(家属账号ID和老人ID的多对多关系),然后查询时强制带上绑定条件。绑定关系建议在管理员分配账号时建立,而不是让家属自助绑定,这样能减少授权风险。
5. 联调、部署与常见问题:这些坑我替你先踩了
5.1 打包部署:jar包方式最省心
这类springboot项目部署,优先选择打jar包方式,而不是war包丢到Tomcat。jar包内嵌Tomcat,部署命令简单,也能利用外部配置文件覆盖内部配置。打包时注意几个问题:
pom.xml中<packaging>保持默认jar即可,不要改成war。- 如果用了MyBatis-Plus的代码生成器,生成代码的目标目录要和maven的
sourceDirectory一致,否则生成出来的代码不在编译路径中,启动时会报找不到Mapper。 - 服务器上启动命令建议加上内存参数:
java -Xms256m -Xmx512m -jar elder-health.jar --spring.config.location=file:/opt/elder-health/config/application.yml,这样可以在不重新打包的情况下调整生产环境配置。
打包后用nohup方式启动:
nohup java -Xms256m -Xmx512m -jar elder-health.jar > app.log 2>&1 &如果nginx在前面做反向代理,需要额外配置上传文件大小限制。健康档案头像、体检报告图片上传是常见场景,SpringBoot默认单文件上传限制1MB,很容易出现“文件大小超过限制”的报错,修改方式有两种:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB同时nginx的client_max_body_size也要同步调大,否则nginx这一层就会直接拒绝。
5.2 常见问题速查表
做这个系统的过程中,有几个问题出现频率特别高,整理成表格方便对照排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 启动时报数据库时区错误 | MySQL驱动版本和数据库时区未设置 | JDBC URL加serverTimezone=Asia/Shanghai |
查询结果中createTime为null | 实体字段和数据库列名映射失败 | 确认开启map-underscore-to-camel-case或手动加@TableField |
| MyBatis-Plus分页查询不生效 | 缺少分页插件配置 | 配置PaginationInnerInterceptor |
| Thymeleaf页面修改后不生效 | 开发阶段未关闭缓存 | spring.thymeleaf.cache=false并重启 |
| 上传图片失败 | 超出SpringBoot或nginx大小限制 | 同时调整multipart配置和nginx配置 |
| 前端跨域请求被阻断 | 前后端分离时未配置CorsFilter | 编写CorsFilter配置类 |
| 定时任务不执行 | 漏了@EnableScheduling | 在启动类加@EnableScheduling |
这里重点说一下分页插件,MyBatis-Plus从3.5.x版本开始,分页插件需要手动添加到MybatisPlusInterceptor:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不加这个配置,selectPage返回的分页数据永远是total=0,所有记录都查出来,看起来像没分页,但实际是分页失效了。
5.3 定时任务与首页统计卡片
老人健康管理系统还有一个常见的功能:首页统计卡片。总老人数、今日新增体检、待随访人数、异常预警数量,这些数据如果每次都实时查数据库,虽然数据量不大,但多个卡片同时查询也存在重复浪费。更合适的方式是写一个定时任务,每5分钟或每小时刷新一次统计结果,存入Redis或者一个统计表:
@Component @Slf4j public class StatisticTask { @Autowired private ElderInfoMapper elderInfoMapper; @Autowired private PhysicalExamMapper physicalExamMapper; @Autowired private AlertRecordMapper alertRecordMapper; @Scheduled(cron = "0 */5 * * * ?") public void refreshStatistics() { // 调用Mapper统计后存入缓存 log.info("首页统计数据刷新完成"); } }注意@Scheduled注解要配合@EnableScheduling使用。cron表达式里?表示不指定值,在“星期”位置用?而不是*,*会导致每秒执行一次,这是一个新手很容易踩的坑。
定时任务还要特别留意一个问题:如果系统部署在多节点(多实例),同样的定时任务会在每个节点都执行一遍,导致统计数据重复计算或短信重复发送。如果只有单机部署,这个问题可以忽略;如果是多节点,建议用分布式锁或把定时任务单独部署到一个节点上。
5.4 一台服务器上部署多个springboot服务的端口管理
如果你在本地开发时同时启动了多个springboot项目(比如网关、业务服务、可视化面板),会出现端口冲突问题。最简单的方式是给每个服务指定不同端口号。但还有一个容易被忽略的地方:SpringBoot Actuator的端口。如果你在pom里引入了spring-boot-starter-actuator,默认管理端口和应用端口相同,如果有多个服务,管理端口也会冲突。需要显式设置:
management: server: port: 8081另外,如果用IDEA启动多个服务实例做调试,记得在Run Configuration中勾选“Allow parallel run”,否则IDEA会直接复用之前的实例,新写的代码不会生效。这一类IDE层面的设置问题,虽然不直接影响线上,但开发效率影响很大。
6. 最后的经验总结:做这类系统最核心的自我修养
就我的实际体会来说,springboot老人健康信息管理系统这类项目,技术和业务在50%和50%之间。技术部分也就是常规的SSM/SpringBoot CRUD,但业务部分的知识深度决定了系统好不好用,比如老人体检指标的参考范围什么时候会变化、不同慢病的随访周期应该怎么设、预警级别怎么定义才算合理。这些业务知识不来自代码,而是来自你真正到养老机构或者社区卫生站去聊过、看过。
一个很实际的建议:如果你在开发这类系统,建议先找一份真实的体检报告单来看。正常的体检报告单上有几十项指标,每一项都有参考范围。把参考范围数据化,做成系统里的字典表,这是最基础的工作。但这个环节恰恰是很多开发忽视的,最后做出来的系统只有血压、血糖、心率三个指标,使用方拿到之后会觉得“不够用”,又说不清楚缺什么。
另外,系统的可配置性远比想象的重要。体检指标类型可以增加,护理等级可以调整,预警阈值不同机构不一样。所以,字典表、配置表要多做几个,哪怕当前版本只有默认数据。这个习惯会给你后续维护省很多事情。
最后,这套系统的扩展方向很多。比如对接体检一体机自动采集数据,或者做成人脸识别开门后自动关联健康打卡,或者给家属开通小程序查询端口。所以开发时接口设计尽量规范,字段命名保持统一,这样后面接任何外部系统都会顺手很多。写代码的时候想着后面可能要对接,但又不能过度设计,这个度就是经验所在。