☰
不止CRUD:Spring Boot健康监控系统建模与预警实战
2026/10/12 1:05:27 网站建设 项目流程

简介:一套基于Spring Boot构建的个人健康监控管理系统项目资源,适合作毕业设计、课程设计,或想快速上手前后端整合的开发者参考。压缩包共2030个文件,大小约104.44MB,以js、css、html等前端静态资源居多,同时包含SQL脚本、JSON配置、Markdown说明与Shell脚本,覆盖页面展示、交互逻辑、数据导入和部署辅助等环节。系统围绕健康档案、日常指标记录与趋势展示等常见功能展开,目录结构基本完整,便于逐层查看Controller、Service和前端页面之间的调用关系,也有利于理解Spring Boot项目从配置到运行的完整链路。前端以Bootstrap、Materialize等样式库为基础,页面组件丰富,稍作调整即可用于其他管理类项目。目前已有180人学习浏览,资源内容较完整,可从中提取健康数据录入、监控看板、用户管理等模块的实现思路,也可复用其前端样式与组件结构,缩短二次开发时间。

1. 基于 Spring Boot 的健康监控系统:为什么说 CRUD 只是入场券

做个人健康监控管理系统,我是从一个具体场景开始的:家里长辈每天早晚量血压,纸质记录写了好几本,复诊时要把历史数据翻出来抄给医生。最初想用 Spring Boot 搭一个能记录心率、血压、血糖和睡眠的小网站,以为重点在页面和增删改查。真正动手才发现,CRUD 只是入场券,数据建模、单位统一、异常识别、数据隔离才是这个系统能不能长期用的关键。

这套基于 Spring Boot 的个人健康监控管理系统,核心不是把数据存进去,而是让数据能回答三个问题:指标在上升还是下降、有没有超出安全范围、哪些记录明显异常。下面从表结构讲起,落到写入、统计、预警的完整实现,再把开发中容易翻车的地方单独拎出来说。

适合想用 Spring Boot 做真实小系统的开发者,也适合手上有健康数据想自己搭工具管理的人。复现不需要多深的框架基础,但要把文中的 SQL 和 Java 代码真正跑一遍,光看不敲,坑会在后面等着你。

2. 健康数据建模与表结构:指标维度、量纲统一和三张核心表

健康监控和普通订单系统最大的差别,在于数据本身有业务含义。订单错了可以退单,健康数据错了,轻则统计报表失真,重则误导健康判断。所以建表这一步别急着写代码,先把数据模型想清楚。我一般会先在纸上列一遍:一条健康记录要回答哪几个问题,然后才是落库。

2.1 健康指标先建模:类型、单位、测量时间一个都不能少

一条健康记录至少要能回答五个问题:谁测的、测的是什么、数值多少、什么时候测的、在什么状态下测的。对应到字段就是 user_id、metric_type、metric_value、measure_time 和 note,缺一个后面做统计都会别扭。

metric_type 我建议用字符串枚举,不用数字。数字 0、1、2 在代码里可读性差,排查问题时要对着字典翻半天。字符串虽然占点空间,但 health_record 表本身就是高写入低更新的结构,可读性比空间更重要。常见取值是 heart_rate、blood_pressure、blood_sugar、weight、sleep 这五类,后面要接体温、血氧也方便扩展。

unit 这个字段经常被忽略,但它是量纲统一的兜底。血压有 mmHg 和 kPa 两种单位,血糖有 mmol/L 和 mg/dL 两种写法。如果入库时只存数值不存单位,后续想换算回去就完全没有依据。我的做法是:入库前在 Service 层统一换算成约定单位,同时把原始单位快照存进 unit 字段。这样展示层想用什么单位展示,都有数据做支撑。

measure_time 和 create_time 是两个概念。measure_time 是业务时间,用户可能补录昨天的数据;create_time 是系统时间,记录什么时候写入的。两者分开存,统计时一律用 measure_time,否则补录行为会直接污染趋势分析。还有一个细节:血压是收缩压和舒张压两个值,不能塞进一个字段里。我用 sub_type 字段区分 systolic 和 diastolic,value 里只存一个数,统计时按 sub_type 分组,这样均值、上限、下限都有意义。

2.2 建表 SQL:用户表、健康记录表、预警规则表怎么设计

个人健康监控系统最小闭环需要三张表:用户表存登录信息,健康记录表存指标数据,预警规则表存阈值配置。下面是实际可用的建表 SQL。

CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(64) NOT NULL COMMENT '登录名', `password_hash` VARCHAR(128) NOT NULL COMMENT 'BCrypt 密码散列', `nickname` VARCHAR(64) DEFAULT '' COMMENT '昵称', `gender` TINYINT DEFAULT 0 COMMENT '0 未知 1 男 2 女', `birthday` DATE DEFAULT NULL COMMENT '出生日期,用于年龄相关分析', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='用户表';
CREATE TABLE `health_record` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `metric_type` VARCHAR(32) NOT NULL COMMENT '指标类型: heart_rate/blood_pressure/blood_sugar/weight/sleep', `sub_type` VARCHAR(16) DEFAULT '' COMMENT '子类型: systolic/diastolic,非血压为空', `metric_value` DECIMAL(10,2) NOT NULL COMMENT '统一换算后的数值', `unit` VARCHAR(16) NOT NULL COMMENT '单位快照: bpm/mmHg/mmol/L/kg/minute', `measure_time` DATETIME NOT NULL COMMENT '测量时间,业务时间', `note` VARCHAR(200) DEFAULT '' COMMENT '备注: 餐前/餐后/运动后等', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_metric_time` (`user_id`, `metric_type`, `measure_time`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='健康记录表';
CREATE TABLE `alert_rule` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', `metric_type` VARCHAR(32) NOT NULL COMMENT '指标类型', `sub_type` VARCHAR(16) DEFAULT '' COMMENT '子类型', `operator` VARCHAR(8) NOT NULL COMMENT 'gt/lt/between', `min_value` DECIMAL(10,2) DEFAULT NULL COMMENT '下限,between 时必填', `max_value` DECIMAL(10,2) DEFAULT NULL COMMENT '上限,gt/lt 时对应填一个', `enabled` TINYINT DEFAULT 1 COMMENT '启用标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_enabled` (`user_id`, `enabled`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='预警规则表';

metric_value 用 DECIMAL(10,2) 不用 FLOAT,原因很简单:浮点数在 MySQL 里做等值比较和聚合会有精度漂移,健康数据里有血压 120.0 这种值,FLOAT 存出来可能是 120.000001,看着就烦。DECIMAL 是定点数,不会有这个问题。睡眠时长我建议统一按分钟存,展示层再转小时,否则 7.5 小时和 450 分钟混在同一个指标里,聚合语义会乱掉。

联合索引 idx_user_metric_time 是趋势查询的命脉。字段顺序按照 user_id → metric_type → measure_time 排列,等值条件在前、范围条件在后,正好匹配最左前缀原则。如果顺序反了,或者漏了 measure_time,统计接口数据量一大就会开始全表扫描,这个坑第 5 章再细说。

2.3 逻辑删除和时间戳:健康数据不能直接物理删

很多人做管理系统习惯物理删除,但在健康监控这个场景我强烈建议用逻辑删除。原因有两条:一是健康记录有追溯价值,用户误删之后想恢复,物理删了就没有后悔药;二是不少场景下需要按周期保留数据,哪怕界面上不展示,数据库里也得留底。

MyBatis-Plus 对逻辑删除支持得很顺,实体上配一个 @TableLogic 注解,全局再配置一下删除值和非删除值,日常查询会自动拼接 deleted = 0 条件。实体定义大概是这样的:

@Data @TableName("health_record") public class HealthRecord { @TableId(type = IdType.AUTO) private Long id; private Long userId; private String metricType; private String subType; private BigDecimal metricValue; private String unit; private LocalDateTime measureTime; private String note; @TableLogic private Integer deleted; private LocalDateTime createTime; private LocalDateTime updateTime; }

注意一点:MyBatis-Plus 自动拼接逻辑删除条件,只对它的内置方法生效。手写 SQL 的时候,比如第 3 章那个趋势统计,一定要自己加 AND deleted = 0,否则统计口径会把已删除记录算进去,数据对不上排查起来很痛苦。

update_time 用 ON UPDATE CURRENT_TIMESTAMP 自动维护,改记录时不用手动 set。这个字段在排查数据问题时很有用:用户说"我昨天没改过数据",一查 update_time 就知道改没改。审计留痕这件事,做的时候多花一分钟,排查时省一小时。

3. 用 Spring Boot 跑通健康监控闭环:写入、统计与预警代码实战

表结构定下来之后,核心代码就是把三个闭环跑通:记录写入、趋势统计、预警触发。这一章给完整可复现的代码,依赖选型、分层结构、关键实现逐段说明。

3.1 工程初始化:依赖选型和分层结构

Spring Boot 版本用 2.7 系列或 3.x 都可以,示例基于 2.7 系列写,迁移到 3.x 主要注意 javax 到 jakarta 的包名替换。持久层我选 MyBatis-Plus 而不是 Spring Data JPA,原因很实际:逻辑删除、分页插件、条件构造器开箱即用,手写 SQL 也直观,中小型管理系统里这是最常见的搭配。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

依赖就四个核心:web 提供接口能力,validation 做参数校验,mybatis-plus 负责持久层,mysql 驱动连库。Lombok 是个人偏好,用来省掉 getter/setter 样板代码。不需要引入 Redis,个人监控系统除非要做分布式锁或缓存,否则 MySQL 完全够用,引入中间件只会增加部署成本。

分包结构我习惯这样组织:

com.example.health ├── controller # 接口入口,只做参数接收和返回 ├── service # 业务逻辑,单位换算、规则校验都在这里 ├── mapper # MyBatis-Plus 的 Mapper 接口 ├── entity # 数据库实体 ├── dto # 入参对象,和实体分离 ├── config # 拦截器、线程池等配置 └── common # 统一返回结果、异常类、上下文工具

controller 里不写业务逻辑,service 里不出现 SQL,mapper 只碰数据库。这个分层看起来笨,但排查问题的时候边界清清楚楚:接口报错先看 controller 参数,数据不对先看 service 逻辑,性能问题先看 mapper 的 SQL。

3.2 健康记录写入接口:参数校验和单位换算

写入接口是整套系统的基础,这里我踩过最大的坑就是单位换算。前端设备有的输出 mmHg,有的输出 kPa,如果后端不统一,统计平均值就毫无意义。所以写入链路我固定三步:先做参数校验,再做单位换算,最后做范围校验落库。

public class HealthRecordDTO { @NotBlank(message = "指标类型不能为空") private String metricType; private String subType; @NotNull(message = "指标数值不能为空") @DecimalMin(value = "0.1", message = "数值不能小于 0.1") private BigDecimal metricValue; @NotBlank(message = "单位不能为空") private String unit; @NotNull(message = "测量时间不能为空") private LocalDateTime measureTime; @Size(max = 200, message = "备注不能超过 200 字") private String note; }

DTO 和实体分离,前端传参不直接绑定数据库结构。注意 userId 不在 DTO 里,当前用户从登录上下文取,这个设计是防止数据越权的关键,第 5 章会讲。

@Service public class HealthRecordService { private final HealthRecordMapper healthRecordMapper; public HealthRecordService(HealthRecordMapper healthRecordMapper) { this.healthRecordMapper = healthRecordMapper; } public Long addRecord(HealthRecordDTO dto, Long currentUserId) { // 第一步:单位统一,血压支持 kPa 和 mmHg 两种写法 BigDecimal value = dto.getMetricValue(); String unit = dto.getUnit(); if ("blood_pressure".equals(dto.getMetricType()) && "kPa".equalsIgnoreCase(unit)) { value = value.multiply(BigDecimal.valueOf(7.5006)) .setScale(1, RoundingMode.HALF_UP); unit = "mmHg"; } // 第二步:范围校验,防止设备异常或手误产生离谱数据 validateRange(dto.getMetricType(), dto.getSubType(), value); // 第三步:组装实体落库 HealthRecord record = new HealthRecord(); record.setUserId(currentUserId); record.setMetricType(dto.getMetricType()); record.setSubType(dto.getSubType()); record.setMetricValue(value); record.setUnit(unit); record.setMeasureTime(dto.getMeasureTime()); record.setNote(dto.getNote()); healthRecordMapper.insert(record); return record.getId(); } }

单位换算里 kPa 转 mmHg 的系数是 7.5006,换算完保留一位小数,用 HALF_UP 四舍五入。120 mmHg 换算成 kPa 是 16.0,16.0 kPa 换算回 mmHg 是 120.0,来回一致才不会出误差。换算逻辑放在 Service 而不是 Controller,保证所有入口走同一套逻辑,不会有接口漏掉。

private void validateRange(String type, String subType, BigDecimal value) { BigDecimal min = rangeMin(type, subType); BigDecimal max = rangeMax(type, subType); if (value.compareTo(min) < 0 || value.compareTo(max) > 0) { throw new BusinessException("数值超出合理范围,请核对测量结果"); } }

范围校验必须在后端做,前端校验可以防手误,但防不了接口被直接调用。个人监控系统的合理范围参考如下:

指标类型子类型合理范围说明
heart_rate-20 ~ 260 bpm覆盖静息与运动极限
blood_pressuresystolic70 ~ 220 mmHg收缩压
blood_pressurediastolic40 ~ 130 mmHg舒张压
blood_sugar-1.0 ~ 33.0 mmol/L覆盖空腹、餐后与极端高糖
weight-20 ~ 300 kg覆盖绝大多数人群
sleep-30 ~ 1440 minute睡眠时长按分钟存

这个范围表不用追求绝对精确,目的是把一眼假的设备异常和手误拦在门外。真正的异常值识别在第 6 章用统计方法做,这里是第一道粗筛。

3.3 趋势统计接口:按天和按周聚合怎么写才准

趋势图是健康监控系统的门面,用户打开系统第一眼看的就是这个。我的实现是 Mapper 里直接写 SQL 做聚合,返回每天的均值、最小值、最大值和记录数,前端画折线图。不把原始记录全查出来再在 Java 里聚合,数据量一大内存就扛不住。

public interface HealthRecordMapper extends BaseMapper<HealthRecord> { @Select(""" SELECT DATE(measure_time) AS day, AVG(metric_value) AS avg_value, MIN(metric_value) AS min_value, MAX(metric_value) AS max_value, COUNT(*) AS cnt FROM health_record WHERE user_id = #{userId} AND metric_type = #{metricType} AND measure_time >= #{start} AND measure_time < #{end} AND deleted = 0 GROUP BY DATE(measure_time) ORDER BY day """) List<TrendRow> selectDailyTrend(@Param("userId") Long userId, @Param("metricType") String metricType, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); }

这段 SQL 里两个细节需要说明。一是条件用 measure_time >= start 和 measure_time < end,半开区间,避免第二天零点整的数据被算到前一天。二是 GROUP BY DATE(measure_time),按天分组,日期函数在索引范围内做分组开销可控。

返回平均值之外还要返回 MIN 和 MAX,是因为健康数据只看均值会掩盖极端波动。比如心率均值 75 bpm 看着正常,但某天晚上飙到 180,那可能是房颤发作,均值里根本看不出。有了 max_value,前端在趋势图上能标出尖峰,这个比均值本身更有临床参考价值。

按周聚合的 SQL 思路一样,只是分组字段换成周。MySQL 里可以用 YEARWEEK(measure_time, 5),第二个参数 mode 5 表示周一作为一周开始,符合国内习惯。注意 YEARWEEK 的边界问题:元旦前后的几天可能被算到上一年的最后一周,如果对跨年周的归属要求严格,建议在应用层用 Java 的时间库先算出每周的起始日期,再按日期范围分组,避免数据库周函数在跨年时的行为不一致。

3.4 定时预警任务:@Scheduled 的规则匹配与去重

预警功能让系统从"记录工具"变成"监控工具"。实现思路是每天定时扫一遍最近 24 小时的记录,和用户配置的规则做匹配,命中就写告警日志。规则表里 operator 是 gt、lt 或 between,对应大于、小于、区间。

@Component public class HealthAlertJob { private final AlertRuleMapper alertRuleMapper; private final HealthRecordMapper healthRecordMapper; private final AlertLogMapper alertLogMapper; @Scheduled(cron = "0 15 8 * * ?") public void checkHealthAlert() { // 1. 查所有启用中的规则 List<AlertRule> rules = alertRuleMapper.selectList( new LambdaQueryWrapper<AlertRule>() .eq(AlertRule::getEnabled, 1)); for (AlertRule rule : rules) { // 2. 查该用户该指标最近 24 小时的记录 LocalDateTime since = LocalDateTime.now().minusHours(24); List<HealthRecord> records = healthRecordMapper.selectList( new LambdaQueryWrapper<HealthRecord>() .eq(HealthRecord::getUserId, rule.getUserId()) .eq(HealthRecord::getMetricType, rule.getMetricType()) .ge(HealthRecord::getMeasureTime, since) .orderByDesc(HealthRecord::getMeasureTime)); // 3. 逐条匹配规则,命中就写告警日志 for (HealthRecord record : records) { if (match(rule, record.getMetricValue())) { saveAlertLogIfAbsent(rule, record); } } } } }

cron 表达式 0 15 8 * * ? 表示每天早上 8 点 15 分执行一次,这个时间点选在用户起床后、量完血压之后,预警信息最及时。match 方法按 operator 分三种情况比较,between 需要同时判断 min 和 max。

预警去重是这个任务最容易出问题的地方。同一个用户同一类指标,早上 8 点测出高血压,10 点复测正常,如果不清除已告警状态,第二天 8 点 15 又会告警一次。我的做法是在 alert_log 表建唯一索引 (rule_id, record_id),saveAlertLogIfAbsent 先查再插,命中同一规则同一条记录就跳过。这样即便任务重复执行,数据层面也天然去重。

@Scheduled 是进程内调度,单实例部署没问题。如果以后要把服务部署成多实例,每个实例都会执行一次这个任务,必须引入分布式锁或者独立的调度服务。这个坑第 5 章展开。

4. Spring Boot 配置与安全设计:数据源、时区、JWT 和数据隔离

代码写得再对,配置不对一样翻车。这一章讲 application.yml 里的关键参数和安全设计,重点是时区配置和用户数据隔离,这两个都是健康监控系统里最容易出隐蔽问题的地方。

4.1 application.yml 关键参数:连接池、时区、逻辑删除

配置文件我一般会把这个版本作为起点:

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/health_monitor ?useUnicode=true&characterEncoding=utf8 &serverTimezone=Asia/Shanghai username: health_app password: ${DB_PASSWORD} hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 max-lifetime: 1800000 jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss task: scheduling: pool: size: 4 mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里每个参数都有它存在的理由:

参数建议值作用
serverTimezoneAsia/Shanghai避免 JDBC 时区转换错位
maximum-pool-size10个人监控系统并发不高,10 足够
max-lifetime1800000连接最大存活 30 分钟,避免被数据库回收
logic-delete-fielddeleted全局逻辑删除字段
map-underscore-to-camel-casetrueuser_id 自动映射 userId

连接串里不写死密码,用 ${DB_PASSWORD} 从环境变量注入,这是安全底线。代码仓库里出现数据库明文密码,等于把服务器钥匙挂在门口。日志用 StdOutImpl 是开发期方便看 SQL,生产环境建议关掉或者换成别的日志实现,否则每一条查询都会打日志,磁盘很快写满。

时区配置为什么单独强调,因为在健康监控系统里,跨零点的测量记录如果时区错位,趋势图的日期分组就会整体漂移。比如用户晚上 23:30 量的血压,如果服务器按 UTC 存储,这条记录会被算到第二天的分组里,用户一看曲线图就觉得系统是玄学。这里统一用 LocalDateTime 表示业务时间,JDBC 连接串和 Jackson 都显式指定 Asia/Shanghai,三处保持一致,就不会有时区问题。

4.2 JWT 登录与数据隔离:用户只能看到自己的数据

健康数据的隐私属性很强,数据隔离不是可选项而是必选项。我的方案是 JWT 做认证,ThreadLocal 存当前用户,接口一律从上下文取用户 ID,不信任前端传参。

JWT 工具类用 jjwt 实现:

@Component public class JwtTokenProvider { private final SecretKey key; public JwtTokenProvider(@Value("${jwt.secret}") String secret) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("uid", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600_000L)) .signWith(key, SignatureAlgorithm.HS256) .compact(); } public Long parseUserId(String token) { Claims claims = Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); return claims.get("uid", Long.class); } }

JWT 过期时间设 7 天,健康监控系统不需要频繁登录,但也不需要更长,安全性和体验取中间值。jwt.secret 从配置读取,至少 32 个字符,别用默认值。

当前用户上下文用 ThreadLocal 实现:

public class UserContext { private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>(); public static void set(Long userId) { USER_ID.set(userId); } public static Long get() { return USER_ID.get(); } public static void clear() { USER_ID.remove(); } }

认证拦截器在请求进入 Controller 之前解析 token,写入上下文,请求结束后必须清理:

@Component public class AuthInterceptor implements HandlerInterceptor { private final JwtTokenProvider jwtTokenProvider; public AuthInterceptor(JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } Long userId = jwtTokenProvider.parseUserId(token.substring(7)); UserContext.set(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }

afterCompletion 里调 clear 不是可有可无。Tomcat 的线程池会复用线程,如果不清理 ThreadLocal,下一个请求会读到上一个用户的 ID,这就是用户串号的开始。这个坑我见过不止一次。

拦截器在 WebMvcConfigurer 里注册,登录接口放行:

@Configuration public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebConfig(AuthInterceptor authInterceptor) { this.authInterceptor = authInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/login"); } }

关键设计是:controller 里所有查询方法都用 UserContext.get() 拼查询条件,DTO 里没有 userId 字段。这样用户 A 无论如何传参,都只能查到 A 自己的数据,数据隔离是结构性的,不是靠约定。有人会把 userId 放在 DTO 里然后校验,也能防越权,但容易漏校验,不如直接从源头拿掉。

5. 避坑:个人健康监控系统开发中的 5 个翻车点

这一章的内容都是从实际开发里总结出来的血泪经验,每个问题都按现象、原因、解决三个步骤写。你照着这个清单排查,能省掉不少调试时间。

5.1 血压单位混用导致数据失真

现象:接入了一台支持 kPa 输出的电子血压计,前端直接把 16.0 当作 16.0 mmHg 入库。血压记录表里出现收缩压 16 mmHg 的离奇数据,虽然有范围校验拦截了一部分,但 16.0 kPa 换算成 mmHg 是 120,转换前的值恰好不会触发校验,导致部分数据悄悄混进统计。

原因:前端设备单位不一致,又没有在录入链路做统一换算。kPa 和 mmHg 的数值差 7.5 倍,混着算均值,结果完全没有参考价值。

解决:写入接口的 Service 层做强制单位换算,kPa 统一转 mmHg,数值乘以 7.5006 保留一位小数。换算代码只放一处,所有入口共用。同时保留 unit 快照字段,方便后续审计。范围校验必须在换算之后执行,否则校验的是原始单位下的值,语义就错了。

5.2 定时预警在多个实例下重复发送

现象:系统部署了两个实例之后,每天早上 8 点 15 分,用户收到两条一模一样的预警短信。刚开始以为是短信平台重复发送,查了半天发现是两个实例各执行了一次任务。

原因:@Scheduled 是进程内调度,每个实例都会独立执行定时任务。单实例没问题,多实例没有互斥机制就会重复。

解决:个人项目单实例部署就不会触发这个问题。要支持多实例,常见做法是引入分布式锁,用 ShedLock 或 Redis 都可以。我个人的偏好是在 alert_log 表上加唯一索引 (rule_id, record_id),写入时先查重再插入,数据层面兜底,即使任务重复执行也只有一个告警落库。这个方案实现简单,排查也直观。

5.3 时区不一致导致趋势图整体错位

现象:用户凌晨 00:30 量的血压,趋势图上显示在了前一天;晚上 23:30 的记录有时跳到了第二天。用户以为是系统计算错误,反复核对后发现是时区问题。

原因:JDBC 连接串没配 serverTimezone,服务器默认 UTC。LocalDateTime 写入 MySQL 时被当成 UTC 时间存储,查询时再按本地时间转换,DATE(measure_time) 分组就错了一天。

解决:连接串显式加 serverTimezone=Asia/Shanghai,Jackson 的 time-zone 也配成 Asia/Shanghai,业务时间统一用 LocalDateTime,不混用 java.util.Date。改完配置后用一条跨零点数据验证 DATE(measure_time) 的结果。这个问题的隐蔽性在于只影响跨零点的记录,白天测的数据完全正常,不仔细对比根本发现不了。

5.4 漏建联合索引导致接口越跑越慢

现象:健康记录到 5 万条时,趋势统计接口从最初的 50 毫秒涨到 2 秒,数据库 CPU 持续飙升。一开始怀疑是 SQL 写得不对,EXPLAIN 一看全表扫描。

原因:趋势查询的 WHERE 条件包含 user_id、metric_type、measure_time 三个字段,但表里只有主键索引,没有对应的联合索引。数据量小的时候 MySQL 用全表扫描也能扛,到几万条就撑不住了。

解决:建联合索引 idx_user_metric_time(user_id, metric_type, measure_time)。字段顺序按等值条件在前、范围条件在后的原则:user_id 和 metric_type 是等值过滤,放前面;measure_time 是范围过滤,放最后。建完索引后用 EXPLAIN 验证 key 字段命中了新索引,rows 扫描行数从几万降到几十。

5.5 接口信任前端 userId 导致数据越权

现象:A 同学登录系统后,把请求里的 userId 改成别人的 ID,直接查到了对方的血压记录和血糖记录。这不是接口报错,而是功能能用,但权限边界完全失效。

原因:接口把 userId 作为前端入参,后端直接用这个值去查数据库,没有和登录用户做绑定。A 同学传 100 就用 100 去查,传 101 就用 101 去查。

解决:删除 DTO 里的 userId 字段,当前用户从拦截器写入的 UserContext 获取。查询记录详情时,SQL 的 WHERE 条件里同时带上 user_id 和主键,即 WHERE id = #{id} AND user_id = #{currentUserId}。这样即使有人猜到别人的记录 ID,查询结果也是空,而不是别人的数据。

6. 让健康监控系统真正可用:异常值清洗与趋势验证

系统上线能跑只是第一步,数据质量才是长期可用的关键。这一章讲两个我一直在用的方法:异常值识别和趋势验证,都是在真实数据上跑过的方案。

6.1 用 3σ 规则识别异常记录

范围校验能拦住明显的错误数据,但拦不住"数值在合理范围内但明显偏离个人基线"的记录。比如一个静息心率常年 70 的人,某天记录了一条 160 的心率,范围校验拦不住,因为 160 在 20 到 260 之间,但这条数据很可能有问题。

我的做法是用 3σ 规则做第二道筛选:取最近 90 天数据,计算均值和标准差,超出均值加减 3 倍标准差范围的记录打上异常标记。健康指标大体符合正态分布,3σ 覆盖了 99.7% 的常规波动,超出这个范围的记录值得人工复核。

代码实现很简单:

public void markAnomalies(Long userId, String metricType) { // 取最近 90 天数据作为基线 List<HealthRecord> records = healthRecordMapper.selectList( new LambdaQueryWrapper<HealthRecord>() .eq(HealthRecord::getUserId, userId) .eq(HealthRecord::getMetricType, metricType) .ge(HealthRecord::getMeasureTime, LocalDateTime.now().minusDays(90))); double avg = records.stream() .mapToDouble(r -> r.getMetricValue().doubleValue()) .average().orElse(0); double std = Math.sqrt(records.stream() .mapToDouble(r -> Math.pow(r.getMetricValue().doubleValue() - avg, 2)) .average().orElse(0)); double low = avg - 3 * std; double high = avg + 3 * std; // 超界记录打标记,不直接删除,等人确认 records.stream() .filter(r -> r.getMetricValue().doubleValue() < low || r.getMetricValue().doubleValue() > high) .forEach(r -> { r.setNote(r.getNote() + " [异常标记]"); healthRecordMapper.updateById(r); }); }

关键点是只标记不删除。自动删除健康数据是大忌,万一误判,比如用户在跑完步之后测的心率 160 本身是正常的,删了就丢了一条真实记录。打上标记之后,用户或开发者可以人工确认,这是健康数据处理的底线。3σ 规则计算的是个人基线而不是通用范围,所以它能发现"对这个人不正常"的数据,比通用范围校验更贴近实际。

6.2 趋势验证的两种低成本方法

异常值清洗做完,还有一个问题:怎么知道统计出来的趋势是对的?我的经验是两种低成本方法结合。

第一,对照已知生理范围。静息心率 60 到 100 bpm,空腹血糖 3.9 到 6.1 mmol/L,这些是公开的医学参考范围。如果系统统计出来的日均心率是 150,先别怀疑数据,先怀疑系统。反过来,如果系统统计值和常识完全吻合,说明数据链路基本正常。

第二,随机抽样核对原始数据。从趋势图上随机选 10 天,把这 10 天的原始记录导出来,逐条核对 measurement_time 和 metric_value,看日期分组是否准确、数值是否有异常跳变。这个方法耗时最多 20 分钟,但能发现很多隐蔽问题:时区错位、单位没换算、补录数据污染统计,都能通过抽样对比暴露出来。

我现在的习惯是:每次改动数据模型或者单位换算逻辑,都导出一段真实数据重新跑一遍趋势接口。曲线和改动前对得上才敢上线,对不上就先去查数据再查代码,不带着疑问发布。健康监控系统的价值全在数据质量上,越往后越值钱,别等报表出错才想起来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询