简介:本资源是一套基于Spring Boot后端与uniapp前端的居民健康监测系统源码,面向Java全栈开发者、小程序学习者及医疗信息化实践者,解决社区级健康数据采集、用户管理与可视化报告等实际场景需求。压缩包共1608个文件,涵盖137个Java后端业务与配置类、233个Vue组件及90个WXML/WXSS小程序页面文件、210个JS逻辑脚本、190个PNG图标与161个SVG矢量图,支撑完整小程序界面与后台功能;另有SQL建表脚本、YML配置、BAT一键部署脚本等实用工程文件,整体30.5MB,结构清晰便于模块化学习与二次开发。目前已有54人下载学习,资源包含可直接运行的管理后台、用户健康档案录入模块、体温/血压/血糖多维数据监测与图表生成能力,以及基于本地路径upload的图片上传存储实现,适合作为毕业设计、课程实训或健康类小程序快速原型参考。
1. 为什么居民健康监测系统必须用 Spring Boot + uniapp 双端协同架构?
一个社区卫生服务中心上线新系统后,发现护士每天要手动录入 200+ 条血压、血糖、心率数据,纸质登记本堆满三抽屉,漏记率超 18%;而老年居民用不了复杂 App,微信里点开 H5 页面又无法调用定位和蓝牙连接家用体征设备。这不是个“做个后台加个前端”就能解决的问题——它需要一套能同时满足政务级数据合规性、多端统一业务逻辑、低门槛用户触达的组合方案。Spring Boot 提供了快速构建符合等保要求的 RESTful 接口、JWT 鉴权、MyBatis-Plus 数据审计、以及对接医院 HIS 系统所需的 JDBC 多数据源能力;uniapp 则让同一套 Vue 代码编译出微信小程序(用于老人子女代操作)、H5(嵌入公众号免安装)、Android/iOS 原生 App(社区医护专用),且能通过uni.getLocation、uni.getConnectedBluetoothDevices、uni.onBLEConnectionStateChange等 API 直接调用设备传感器。这不是技术炫技,而是当「居民在家测一次血压」和「社区医生在 PC 端看到实时预警」必须发生在同一套业务流里时,唯一可落地的工程选择。
2. Spring Boot 后端:四层架构如何支撑健康数据的强一致性与审计追溯
居民健康监测系统不是普通 CRUD 应用。一次血压测量值可能触发慢病随访流程,一条异常心率记录需联动家庭医生工作站,所有操作必须留痕、可回溯、防篡改。Spring Boot 的分层设计不是教科书模板,而是应对真实监管要求的技术契约。
2.1 四层结构选型依据与包结构映射
| 层级 | 包路径示例 | 核心职责 | 为何不可省略 |
|---|---|---|---|
| Controller | com.health.controller | 接收 uniapp 请求(JSON)、校验基础参数(如@Valid @RequestBody BloodPressureDTO)、返回统一响应体(含code=200/400/500) | 承担第一道防线:防止非法字段穿透、控制接口幂等性(如@PostMapping(value = "/v1/measure", produces = MediaType.APPLICATION_JSON_VALUE)) |
| Service | com.health.service.impl | 编排业务逻辑:校验用户是否绑定家庭医生、判断收缩压是否 ≥140mmHg 触发预警、生成随访任务 ID 并写入消息队列 | 避免 Controller 耦合数据库操作;此处是规则引擎入口,例如if (bp.getSystolic() >= 140) { alertService.sendUrgentAlert(userId); } |
| Mapper / Repository | com.health.mapper(MyBatis)或com.health.repository(JPA) | 仅执行单表增删改查;MyBatis XML 中强制使用<selectKey>生成 UUID 主键,禁用useGeneratedKeys="true"(避免 MySQL 自增 ID 泄露数据量) | 满足等保 2.0 对“数据操作最小权限”要求;所有 SQL 必须显式声明字段,禁止SELECT * |
| Entity / DTO / VO | com.health.entity/dto/vo | Entity 带@Table(name = "t_blood_pressure")映射物理表;DTO 用于接收前端参数(含@NotNull注解);VO 用于返回脱敏数据(如mobile: "138****1234") | 实现数据传输对象隔离:Entity 不暴露给前端,VO 不含敏感字段(身份证号经@JsonIgnore或@Transient过滤) |
提示:不要用 Lombok 的
@Data全局开启 getter/setter——健康数据字段如bloodSugar必须区分读写权限(@Getter+@Setter(access = AccessLevel.PRIVATE)),防止外部误赋值。
2.2 关键配置项:让健康数据真正“可追溯”
Spring Boot 的application.yml中,以下配置直接决定系统能否通过卫健部门数据质量检查:
spring: datasource: url: jdbc:mysql://health-db:3306/health_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: health_app password: ${DB_PASSWORD} # 从环境变量注入,禁止明文 hikari: connection-timeout: 30000 maximum-pool-size: 20 # 强制开启 SQL 执行日志(仅开发/测试环境) >@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { // 插入时自动填充创建人、时间 this.strictInsertFill(metaObject, "createdBy", String.class, "system"); // 默认系统创建 this.strictInsertFill(metaObject, "createdTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updatedBy", String.class, "system"); this.strictInsertFill(metaObject, "updatedTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "deleted", Integer.class, 0); // 逻辑删除默认 0 } @Override public void updateFill(MetaObject metaObject) { // 更新时只更新 updatedBy 和 updatedTime this.strictUpdateFill(metaObject, "updatedBy", String.class, "system"); this.strictUpdateFill(metaObject, "updatedTime", LocalDateTime.class, LocalDateTime.now()); } }这段代码确保每条血压记录入库时,created_time字段精确到毫秒、created_by记录操作来源(如wechat_mini_program_v2.3),而非简单设为new Date()。当卫健监督员抽查某居民 2024 年 Q3 血压趋势时,系统能导出含完整操作链路的 Excel:谁在何时、通过哪个终端、录入了哪组数值。
2.3 健康数据特有的事务边界设计
一次完整的“家庭医生上门随访”操作包含:
① 更新居民体征数据(t_blood_pressure)
② 新增随访记录(t_follow_up)
③ 修改居民档案状态(t_resident_profile.status = 'under_care')
④ 向家属推送微信模板消息(调用微信 API)
这四个动作必须原子性成功或全部回滚。但第④步是外部 HTTP 调用,无法纳入本地数据库事务。解决方案是Saga 模式 + 补偿事务:
@Service @Transactional(rollbackFor = Exception.class) public class FollowUpService { @Transactional(propagation = Propagation.REQUIRED) public void startFollowUp(FollowUpRequest request) throws Exception { // 步骤①②③ 在同一事务中执行 bloodPressureMapper.insert(request.getBp()); followUpMapper.insert(request.getFollowUp()); profileMapper.updateStatus(request.getResidentId(), "under_care"); // 步骤④ 单独异步执行,失败则记录补偿任务 try { wechatService.sendTemplateMessage(request.getFamilyOpenId(), request); } catch (Exception e) { // 写入补偿表 t_compensation_task,由定时任务重试 compensationTaskMapper.insert(new CompensationTask( "SEND_WECHAT_MSG", request.getFamilyOpenId(), JSON.toJSONString(request), 3 // 最多重试 3 次 )); } } }这种设计让系统在微信服务临时不可用时,仍能保证核心健康数据一致性,同时不阻塞业务主流程——这是居民健康系统区别于电商系统的根本特征:数据正确性优先于即时响应。
3. uniapp 前端:如何用一套代码覆盖微信小程序、H5 公众号、App 三端健康监测场景
uniapp 的价值不在“一次开发多端运行”的宣传语,而在于它解决了健康监测场景下最棘手的三个矛盾:老人不会装 App 但会点微信、社区医生需要离线扫码录入但 H5 无蓝牙权限、子女想实时查看父母数据却受限于不同平台登录态。这些需求无法靠 WebView 或纯 H5 满足,必须深度利用 uniapp 的条件编译和原生能力桥接。
3.1 条件编译实现三端差异化功能
uniapp 的/* #ifdef MP-WEIXIN */和/* #ifdef APP-PLUS */不是锦上添花,而是功能落地的前提。以“获取当前位置并显示附近社区卫生站”为例:
<template> <view class="map-container"> <!-- 微信小程序端:使用 wx.getLocation + wx.openLocation --> /* #ifdef MP-WEIXIN */ <button @click="openWeixinMap">打开微信地图</button> /* #endif */ <!-- H5 端(公众号内):使用 HTML5 Geolocation API --> /* #ifdef H5 */ <button @click="openH5Map">定位并查找站点</button> /* #endif */ <!-- App 端:调用 plus.geolocation 获取高精度定位 --> /* #ifdef APP-PLUS */ <button @click="openAppMap">启动原生地图</button> /* #endif */ </view> </template> <script> export default { methods: { /* #ifdef MP-WEIXIN */ openWeixinMap() { uni.getLocation({ type: 'gcj02', // 火星坐标系,适配微信地图 success: (res) => { uni.openLocation({ latitude: res.latitude, longitude: res.longitude, name: '最近社区卫生站', address: 'XX街道社区卫生服务中心' }); } }); } /* #endif */ /* #ifdef H5 */ openH5Map() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { const lat = position.coords.latitude; const lng = position.coords.longitude; // 调用高德 JS API 渲染地图 this.initAMap(lat, lng); }, (err) => { uni.showToast({ title: '请允许位置权限', icon: 'none' }); } ); } } /* #endif */ /* #ifdef APP-PLUS */ openAppMap() { // plus.geolocation 是 5+ Runtime 提供的原生定位 API,精度更高 plus.geolocation.getCurrentPosition( (p) => { // 启动原生地图应用 plus.runtime.openURL(`geo:${p.coords.latitude},${p.coords.longitude}`); }, (e) => { console.error('定位失败: ' + e.message); } ); } /* #endif */ } }; </script>这段代码的关键在于:微信小程序用wx.openLocation跳转微信内置地图(符合微信生态规范),H5 用浏览器标准 API(避免被公众号拦截),App 端用plus.geolocation获取亚米级精度(满足医护上门导航需求)。若强行用一套uni.getLocation统一调用,H5 端在 iOS 微信中将因 Safari 安全策略无法获取定位,导致功能瘫痪。
3.2 健康设备直连:uniapp 如何驱动蓝牙血压计与 NFC 健康卡
居民在家测血压,最理想路径是:打开微信小程序 → 点击“连接设备” → 自动发现已配对蓝牙血压计 → 一键同步数据到云端。这需要突破 uniapp 默认能力边界:
步骤 1:配置 manifest.json 启用原生权限
{ "name": "居民健康监测", "appid": "__UNI__XXXXXXX", "description": "", "versionName": "2.3.0", "versionCode": "230", "transformPx": false, "splashscreen": { "alwaysShowBeforeRender": true, "waiting": true, "autoclose": true, "delay": 0 }, "permissions": { "android": { "uses-permission": [ "android.permission.BODY_SENSORS", "android.permission.ACCESS_FINE_LOCATION", "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", "android.permission.ACCESS_COARSE_LOCATION" ] }, "ios": { "UIBackgroundModes": ["bluetooth-central"], "NSBluetoothAlwaysUsageDescription": "需要蓝牙权限同步血压计数据", "NSLocationWhenInUseUsageDescription": "需要定位权限查找附近社区卫生站" } } }注意:
BODY_SENSORS权限是 Android 10+ 读取蓝牙医疗设备(如血压计、血糖仪)的必要条件,缺此权限会导致uni.getConnectedBluetoothDevices返回空数组。
步骤 2:编写蓝牙连接与数据解析逻辑
// utils/bluetooth.js export const connectBloodPressureDevice = async () => { try { // 1. 初始化蓝牙适配器 await uni.openBluetoothAdapter(); // 2. 开始搜索(App 端需先申请位置权限) /* #ifdef APP-PLUS */ await new Promise((resolve) => { plus.android.importClass('android.Manifest'); plus.android.requestPermissions(['android.permission.ACCESS_FINE_LOCATION'], () => { resolve(); }, () => { uni.showToast({ title: '请授予定位权限', icon: 'none' }); }); }); /* #endif */ // 3. 搜索指定服务 UUID 的设备(血压计通用 UUID) const devices = await uni.searchBluetoothDevices({ services: ['00001810-0000-1000-8000-00805f9b34fb'] // Blood Pressure Service }); if (devices.deviceList.length === 0) { throw new Error('未发现血压计设备'); } // 4. 连接设备并读取特征值 const deviceId = devices.deviceList[0].deviceId; await uni.createBLEConnection({ deviceId }); // 5. 启用通知(血压计数据通过 notify 通道发送) await uni.notifyBLECharacteristicValueChange({ state: true, deviceId, serviceId: '00001810-0000-1000-8000-00805f9b34fb', characteristicId: '00002a35-0000-1000-8000-00805f9b34fb' // Blood Pressure Measurement }); // 6. 监听数据变化(实际项目中需解析 IEEE-11073 医疗协议) uni.onBLECharacteristicValueChange((res) => { const buffer = new Uint8Array(res.value); const systolic = buffer[3] | (buffer[4] << 8); // 收缩压(单位 mmHg) const diastolic = buffer[5] | (buffer[6] << 8); // 舒张压 const pulse = buffer[7] | (buffer[8] << 8); // 心率 // 上报到 Spring Boot 后端 uni.request({ url: 'https://api.health.gov/v1/measure/blood-pressure', method: 'POST', data: { systolic, diastolic, pulse, deviceId }, header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') } }); }); } catch (err) { console.error('蓝牙连接失败:', err); uni.showToast({ title: '连接失败,请检查设备电量与蓝牙开关', icon: 'none' }); } };这段代码展示了 uniapp 如何绕过 H5 限制,在 App 端实现真正的医疗设备直连。关键点在于:notifyBLECharacteristicValueChange启用通知后,血压计每测量一次就主动推送二进制数据帧,前端无需轮询,降低功耗;解析逻辑严格遵循 Bluetooth SIG 的 Blood Pressure Service 规范(GATT UUID00001810-...),确保兼容市面 90% 以上国产血压计。
3.3 微信公众号 H5 的特殊处理:授权、定位、分享闭环
H5 端嵌入微信公众号,面临三大限制:
- 微信浏览器禁用
navigator.geolocation(iOS 尤甚) - 公众号菜单跳转 H5 时
window.location.href会丢失code参数 - 分享到好友需调用
wx.config但签名必须后端生成
解决方案是服务端预签名 + 前端静默授权:
// main.js 全局初始化 const initWechatConfig = async () => { try { // 1. 从后端获取签名(Spring Boot 提供 /api/wechat/sign?url=xxx) const res = await uni.request({ url: 'https://api.health.gov/v1/wechat/sign', data: { url: encodeURIComponent(window.location.href.split('#')[0]) } }); // 2. 配置微信 JSSDK wx.config({ debug: false, appId: res.data.appId, timestamp: res.data.timestamp, nonceStr: res.data.nonceStr, signature: res.data.signature, jsApiList: ['getLocation', 'updateAppMessageShareData', 'updateTimelineShareData'] }); // 3. 静默获取用户 openid(无需弹窗授权) const code = getQueryString('code'); // 从公众号菜单带入的 code if (code && !uni.getStorageSync('openid')) { const userRes = await uni.request({ url: 'https://api.health.gov/v1/wechat/user-info', data: { code } }); uni.setStorageSync('openid', userRes.data.openid); uni.setStorageSync('userInfo', userRes.data.userInfo); } } catch (e) { console.warn('微信配置失败,降级为普通 H5 功能'); } }; // 页面 mounted 时调用 export default { mounted() { initWechatConfig(); } };后端 Spring Boot 对应接口:
@RestController @RequestMapping("/api/wechat") public class WeChatController { @GetMapping("/sign") public Map<String, String> getSignature(@RequestParam String url) { String jsapiTicket = weChatService.getJsapiTicket(); // 从 Redis 缓存获取有效 ticket String nonceStr = UUID.randomUUID().toString().replace("-", ""); long timestamp = System.currentTimeMillis() / 1000; String string1 = "jsapi_ticket=" + jsapiTicket + "&noncestr=" + nonceStr + "×tamp=" + timestamp + "&url=" + url; String signature = DigestUtils.sha256Hex(string1); Map<String, String> result = new HashMap<>(); result.put("appId", "wx1234567890abcdef"); result.put("timestamp", String.valueOf(timestamp)); result.put("nonceStr", nonceStr); result.put("signature", signature); return result; } }这套机制让居民点击公众号菜单进入 H5 后,自动完成身份绑定、获取定位(通过wx.getLocation)、并支持一键分享体检报告到微信群——所有操作在 3 秒内完成,无需二次跳转或弹窗,极大降低老年用户操作门槛。
4. 数据贯通实战:Spring Boot 与 uniapp 如何实现“测量-预警-随访”端到端闭环
一个高血压患者在家用蓝牙血压计测量,数据上传后,系统需在 5 秒内完成:① 存储原始数据 ② 判断是否超标 ③ 若超标则向家庭医生推送企业微信消息 ④ 向患者家属发送微信模板消息 ⑤ 在医生工作台首页展示待处理预警卡片。这个闭环不是靠增加服务器性能,而是靠精准的消息路由与状态机设计。
4.1 健康数据上报接口的幂等性与防重设计
uniapp 端网络不稳定时,用户可能连续点击“同步数据”按钮,导致同一组血压值重复提交。Spring Boot 必须拒绝重复请求,而非简单去重:
@PostMapping("/v1/measure/blood-pressure") public Result<?> saveBloodPressure(@Valid @RequestBody BloodPressureDTO dto, HttpServletRequest request) { // 1. 从请求头提取设备指纹(uniapp 端通过 plus.device.uuid 或 wx.getSystemInfoSync().model 生成) String deviceFingerprint = request.getHeader("X-Device-Fingerprint"); if (StringUtils.isBlank(deviceFingerprint)) { return Result.fail("设备标识缺失"); } // 2. 构建业务唯一键:设备ID + 测量时间戳(精确到秒) + 收缩压值 String uniqueKey = String.format("%s_%d_%d", deviceFingerprint, dto.getMeasuredAt() / 1000, // 转为秒级时间戳 dto.getSystolic()); // 3. 使用 Redis SETNX 实现分布式锁(有效期 10 秒,防死锁) Boolean isLocked = redisTemplate.opsForValue() .setIfAbsent("bp:lock:" + uniqueKey, "1", Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(isLocked)) { return Result.fail("数据已存在,无需重复提交"); } try { // 4. 执行业务逻辑(存储、预警、推送) bloodPressureService.handleNewMeasurement(dto, deviceFingerprint); return Result.success(); } finally { // 5. 释放锁 redisTemplate.delete("bp:lock:" + uniqueKey); } }注意:
deviceFingerprint不能直接用plus.device.uuid(部分 Android 厂商限制),推荐方案是 uniapp 端组合plus.device.uuid + plus.system.model + Date.now().toString().substr(-6)生成 32 位字符串,既保证唯一性又规避隐私风险。
4.2 预警规则引擎:用 Groovy 脚本动态配置慢病阈值
卫健委对不同年龄段、不同基础病患者的血压控制目标不同(如糖尿病患者收缩压应 <130mmHg)。硬编码 if-else 无法应对政策调整。Spring Boot 集成 Groovy 实现规则热加载:
@Service public class AlertRuleEngine { @Autowired private ScriptEngineManager scriptEngineManager; // 从数据库读取规则脚本(支持动态更新) @PostConstruct public void loadRules() { String ruleScript = "if (bp.systolic >= 140 || bp.diastolic >= 90) { return 'HIGH_RISK'; }" + "else if (bp.systolic >= 130 && patient.hasDiabetes) { return 'MEDIUM_RISK'; }" + "else { return 'NORMAL'; }"; CompiledScript compiled = ((Compilable) scriptEngineManager.getEngineByName("groovy")) .compile(ruleScript); // 缓存 compiled script,避免每次执行都编译 } public String evaluateRisk(BloodPressure bp, Patient patient) { ScriptEngine engine = scriptEngineManager.getEngineByName("groovy"); Bindings bindings = engine.createBindings(); bindings.put("bp", bp); bindings.put("patient", patient); try { return (String) engine.eval("if (bp.systolic >= 140 || bp.diastolic >= 90) { 'HIGH_RISK' } else if (bp.systolic >= 130 && patient.hasDiabetes) { 'MEDIUM_RISK' } else { 'NORMAL' }", bindings); } catch (ScriptException e) { log.error("规则执行失败", e); return "UNKNOWN"; } } }当卫健部门下发新文件《关于老年高血压患者管理目标的补充通知》,运维只需在管理后台修改数据库中的 Groovy 脚本,无需重启服务、无需发版,5 分钟内所有新测量数据即按新规判定风险等级。
4.3 三端状态同步:用 WebSocket 实现实时预警推送
医生在 PC 端工作台看到预警卡片,家属在微信收到模板消息,患者在 App 端看到“您的血压偏高,请及时联系医生”提示——这三个动作必须强一致。Spring Boot 使用@EnableWebSocketMessageBroker搭建 STOMP 消息总线:
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic", "/queue"); // 允许订阅 topic 和 queue config.setApplicationDestinationPrefixes("/app"); // 客户端发送消息前缀 config.setUserDestinationPrefix("/user"); // 用户专属消息前缀 } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS(); // 兼容 IE } } // 推送预警到医生端(按科室订阅) @Service public class AlertPublisher { @Autowired private SimpMessagingTemplate messagingTemplate; public void sendToDoctor(String department, Alert alert) { messagingTemplate.convertAndSend("/topic/doctor/" + department, alert); } // 推送消息到指定用户(家属微信 openid) public void sendToUser(String openid, Object message) { messagingTemplate.convertAndSendToUser(openid, "/queue/alert", message); } }uniapp 端连接 WebSocket:
// store/modules/websocket.js const state = { stompClient: null }; const mutations = { SET_STOMP_CLIENT(state, client) { state.stompClient = client; } }; const actions = { connectWebSocket({ commit }) { const socket = new SockJS('https://api.health.gov/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, () => { commit('SET_STOMP_CLIENT', stompClient); // 订阅医生科室预警 stompClient.subscribe('/topic/doctor/cardiology', (message) => { const alert = JSON.parse(message.body); uni.showToast({ title: `心内科预警:${alert.patientName} 血压异常`, icon: 'none' }); }); // 订阅个人消息(家属端) stompClient.subscribe('/user/queue/alert', (message) => { const msg = JSON.parse(message.body); uni.showModal({ title: '健康提醒', content: msg.content }); }); }); } };当血压数据入库触发预警,Spring Boot 同时向/topic/doctor/cardiology和/user/queue/alert发送消息,三端在 200ms 内收到相同事件,且 WebSocket 连接断开时自动重连,保障关键消息不丢失——这才是居民健康监测系统应有的实时性。
5. 生产环境关键技巧:如何让 Spring Boot + uniapp 系统通过等保三级测评
等保三级不是“加个防火墙就完事”,而是对数据全生命周期的刚性约束。Spring Boot + uniapp 组合在测评中最常被扣分的三个点:健康数据传输未加密、日志留存不足 180 天、API 未做细粒度权限控制。以下技巧直接对应测评条款。
5.1 HTTPS 强制跳转与敏感字段 AES 加密传输
即使 Nginx 已配置 SSL,uniapp 端仍可能因开发疏忽调用 HTTP 接口。Spring Boot 必须在网关层拦截:
@Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/actuator/**").permitAll() // actuator 开放,但需后续加固 .requestMatchers("/v1/measure/**").authenticated() // 健康数据接口需登录 .anyRequest().permitAll() ) .requiresChannel(channel -> channel .requestMatchers(r -> r.getRequestURI().startsWith("/v1/")) .requiresSecure() // 强制 HTTPS,HTTP 请求直接 301 ); return http.build(); } }但仅 HTTPS 不够——等保要求“重要数据传输加密”。血压值、血糖值属于《个人信息安全规范》定义的“个人健康生理信息”,需额外 AES 加密:
// uniapp 端加密(使用 crypto-js) import CryptoJS from 'crypto-js'; const encryptHealthData = (data, key) => { const iv = CryptoJS.enc.Utf8.parse('1234567890123456'); // 16字节 IV const encrypted = CryptoJS.AES.encrypt(JSON.stringify(data), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); }; // Spring Boot 端解密 @Component public class AesDecryptor { private static final String KEY = "HealthMonitor2024!"; // 生产环境从 Vault 获取 private static final String IV = "1234567890123456"; public BloodPressureDTO decrypt(String encryptedJson) { try { byte[] keyBytes = KEY.getBytes(StandardCharsets.UTF_8); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes(StandardCharsets.UTF_8)); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes = Base64.getDecoder().decode(encryptedJson); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); String decryptedJson = new String(decryptedBytes, StandardCharsets.UTF_8); return new ObjectMapper().readValue(decryptedJson, BloodPressureDTO.class); } catch (Exception e) { throw new RuntimeException("AES 解密失败", e); } } }提示:AES 密钥
KEY绝不能写死在代码中,生产环境必须通过 HashiCorp Vault 或阿里云 KMS 动态获取,每次请求都调用 KMS Decrypt API 解密密钥。
5.2 日志审计:用 Logback 实现健康操作日志留存 180 天
等保要求“审计日志留存不少于 180 天”。Logback 配置必须禁用滚动策略的maxHistory陷阱(很多教程写的maxHistory="90"实际只存 90 个文件,非 90 天):
<!-- logback-spring.xml --> <appender name="HEALTH_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/health-audit.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 每天一个文件,文件名含日期 --> <fileNamePattern>logs/health-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <!-- 保留 180 天的日志文件 --> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> <!-- 关键:设置 maxHistory 为 180,表示最多保留 180 天的文件夹 --> <maxHistory>180</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 审计专用 Logger --> <logger name="com.health.audit" level="INFO" additivity="false"> <appender-ref ref="HEALTH_AUDIT"/> </logger>在业务代码中记录审计日志:
@Service public class BloodPressureService { private static final Logger AUDIT_LOGGER = LoggerFactory.getLogger("com.health.audit"); public void save(BloodPressure bp) { // ... 保存逻辑 AUDIT_LOGGER.info("HEALTH_DATA_UPLOAD|USER_ID:{}|DEVICE_ID:{}|SYSTOLIC:{}|DIASTOLIC:{}|IP:{}", bp.getUserId(), bp.getDeviceId(), bp.getSystolic(), bp.getDiastolic(), RequestContextHolder.currentRequestAttributes().getAttribute("remoteAddr", RequestAttributes.SCOPE_REQUEST)); } }日志格式严格按HEALTH_DATA_UPLOAD|USER_ID:1001|DEVICE_ID:BP-2024-001|SYSTOLIC:142|DIASTOLIC:92|IP:119.123.45.67输出,便于 ELK 做字段提取与关联分析——测评时审计组会随机抽取 3 个用户,要求提供其近 180 天所有健康数据操作日志,格式错误即判不合格。
5.3 API 权限精细化:基于 ResolvedPermission 的动态鉴权
Spring Security 默认的@PreAuthorize("hasRole('DOCTOR')")过于粗放。家庭医生只能查看自己签约居民的数据,社区护士只能操作本辖区数据,这种行级权限必须动态计算:
@Component public class HealthDataPermissionEvaluator implements PermissionEvaluator { @Autowired private ResidentService residentService; @Override public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { if (!(targetDomainObject instanceof BloodPressure)) { return false; } BloodPressure bp = (BloodPressure) targetDomainObject; String currentUserId = authentication.getName(); // 获取当前用户角色 Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); boolean isDoctor = authorities.stream().anyMatch(a -> a.getAuthority().equals("ROLE <p> <a href="https://download.csdn.net/download/2501_90323865/91056397" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>